Compare commits

..
10 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.8 96e3e0ba10 chore: remove packaging/test-pack (combined test-install PKGBUILD no longer needed)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:35:43 -04:00
molluskandClaude Opus 4.8 bf4d9b100f docs(changelog): note MIT license adoption under Unreleased
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:31:51 -04:00
molluskandClaude Opus 4.8 91ef5b0a72 chore: remove antigravity.toml (Gemini agent config, no longer used)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:30:59 -04:00
molluskandClaude Opus 4.8 47be7c340d license: adopt MIT for the application + add THIRD_PARTY_LICENSES
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Set license = "MIT" (Cargo.toml), add the MIT LICENSE file, and switch the
PKGBUILD to license=('MIT'). Generate a THIRD_PARTY_LICENSES file enumerating
the full dependency-graph manifest plus the canonical text of every referenced
license, with notices for the statically-bundled Opus codec and the embedded
fonts (Iced-Icons, Cantarell/OFL-1.1). Ship both files in the .deb and Arch
packages. Update README License section and stale private-build metadata.

Codex (gpt-5.5) audited the tree and confirmed no copyleft blocks MIT; the
remaining items were asset provenance + binary-distribution notice hygiene,
which THIRD_PARTY_LICENSES addresses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:24:43 -04:00
molluskandClaude Opus 4.8 1c8c37b248 docs(readme): drop clang from Arch deps
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:53:20 -04:00
molluskandClaude Opus 4.8 08792809d6 docs: add project README with screenshots, features, roadmap, and build instructions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:52:01 -04:00
molluskandClaude Opus 4.8 9ff7c7b99c packaging(windows): bump installer version to 0.6.0
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Matches the 0.6.0 release; the Inno Setup MyAppVersion drives the
output filename (peerspeak-0.6.0-setup.exe) and the installed
AppVersion/uninstall entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 07:09:34 -04:00
molluskandClaude Opus 4.8 3ff0945866 packaging: remap build paths out of the binary (fix $srcdir reference warning)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
makepkg warned that usr/bin/peerspeak referenced $srcdir: Rust bakes source
paths into panic/backtrace metadata that survives stripping. Add
--remap-path-prefix=$srcdir=/ in build() so neither our sources nor the
vendored deps under CARGO_HOME leave the build dir embedded in the package.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 06:19:59 -04:00
molluskandClaude Opus 4.8 618a53027d Merge W22: click-to-enlarge image lightbox for chat images
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 06:16:23 -04:00
molluskandClaude Opus 4.8 6054f0ecf9 W22: click-to-enlarge image lightbox for chat images
Clicking an inline chat image now opens it enlarged in a full-window
overlay. The image renders contain-fit (scaled down, never cropped) over
an 80% dimmed backdrop with a ✕ button pinned top-right. The overlay
closes four ways: Esc, clicking the backdrop, clicking the image, or the
✕ button.

- AppMessage: OpenImageLightbox(AttachmentKey) / CloseImageLightbox
- AppState.image_lightbox: Option<AttachmentKey> (init None, cleared on
  leave in reset_room_state)
- inline image wrapped in a mouse_area with a pointer cursor
- with_image_lightbox overlay modeled on with_regenerate_confirm; guarded
  cache lookup so an evicted handle can't panic
- Esc handled at the top of the KeyPressed arm so it takes priority over
  user-bound hotkeys while the overlay is open

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 00:13:08 -04:00
15 changed files with 2127 additions and 277 deletions
+5
View File
@@ -2,6 +2,11 @@
All notable changes to PeerSpeak are documented here.
## [Unreleased]
### Licensing
- **PeerSpeak is now released under the MIT License** (previously an unlicensed private build). Added a `LICENSE` file and a `THIRD_PARTY_LICENSES` file enumerating the full dependency manifest plus the canonical text of every referenced license, with notices for the statically bundled Opus codec and the embedded fonts (Iced-Icons, Cantarell/OFL-1.1). Both files ship in the Arch and Debian packages.
## [0.6.0] — 2026-06-28
### Added
+5 -3
View File
@@ -3,8 +3,8 @@ name = "peerspeak"
version = "0.6.0"
edition = "2024"
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
# Application crate, not a crates.io library — refuse `cargo publish` and let
# cargo-deny's [licenses.private] skip the missing-license check.
license = "MIT"
# Application crate, not published to crates.io — refuse `cargo publish`.
publish = false
# Debian/Ubuntu packaging (cargo-deb). Mirrors packaging/PKGBUILD: only the main
@@ -14,7 +14,7 @@ publish = false
# distrobox so the binary links that distro's glibc, then `cargo deb --no-build`.
[package.metadata.deb]
maintainer = "mollusk <jitty+lc1iz0dc@protonmail.com>"
copyright = "2026, mollusk. Private build — not for redistribution."
copyright = "2026, mollusk. MIT License."
section = "net"
priority = "optional"
depends = "$auto"
@@ -33,6 +33,8 @@ assets = [
["assets/icons/peerspeak-128.png", "usr/share/icons/hicolor/128x128/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-256.png", "usr/share/icons/hicolor/256x256/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-512.png", "usr/share/icons/hicolor/512x512/apps/peerspeak.png", "644"],
["LICENSE", "usr/share/doc/peerspeak/", "644"],
["THIRD_PARTY_LICENSES", "usr/share/doc/peerspeak/", "644"],
]
[lib]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 mollusk
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+115
View File
@@ -0,0 +1,115 @@
<h1>
<img src="assets/icons/peerspeak.svg" width="48" align="left" alt="PeerSpeak icon">
PeerSpeak
</h1>
Decentralized, peer-to-peer voice chat — full-mesh, NAT-traversing, with **no central server**. Built in Rust on [iroh](https://github.com/n0-computer/iroh) (QUIC), PipeWire audio, the Opus codec, and an [iced](https://github.com/iced-rs/iced) GUI.
Create a room, share the join ticket, and talk. Everyone connects directly to everyone else; relays are only used to punch through NATs when a direct path isn't available.
---
## Screenshots
| Launch screen | In a room |
|---|---|
| ![Launch screen](docs/screenshots/launch.jpg) | ![In a room](docs/screenshots/in-room.jpg) |
| Settings |
|---|
| ![Settings](docs/screenshots/settings.jpg) |
---
## Features
**Rooms & sessions**
- Create a room → shareable join ticket; join by pasting a ticket.
- Full-mesh multi-peer rooms with live presence.
- Recent-rooms list to hop back into a room someone's still in.
- Remembered nickname and in-call duration timer.
**Audio**
- PipeWire capture/playback, selectable input and output devices, per-app gain.
- Opus codec (48 kHz mono, 20 ms frames) with an adaptive jitter buffer + packet-loss concealment.
- Noise gate with a draggable threshold on a live mic meter (test your mic off-call too).
- Mix-bus soft limiter and opt-in echo cancellation (PipeWire WebRTC AEC + noise suppression).
**Voice controls**
- Self-mute, deafen, and rebindable push-to-talk.
- Per-peer volume, local mute, and speaking indicators.
**Text chat**
- In-room text chat over the gossip plane, with clickable links and inline image/audio attachments.
- Drag-selectable, copyable messages; right-click context menu on all text fields.
**Shared music listening**
- Build a personal playlist of local audio files with a full transport (play/pause, seek, reorder).
- Let others tune in: peers stream your current track, timeline-synced and gapless, sitting under voice at their own volume.
**Screen share** (via [pixelpass](https://gitbutter.xyz/mollusk/pixelpass))
- Share your screen; peers click 👁 Watch to open the stream in mpv (vlc fallback).
- Live badges on sharing peers; per-app audio capture.
**Recording & notifications**
- Local call recording (mic + incoming mix → WAV in `~/peerspeak-recordings/`).
- Desktop notifications and event chimes with per-event custom sound overrides.
**UI & networking**
- Selectable room layouts (3-Column, Bottom Dock, Drawer) with draggable, persisted dividers.
- 10 built-in themes (Catppuccin, Dracula, Nord, Tokyo Night, Gruvbox, Solarized…), all WCAG-AA checked.
- Network mode picker (relay-no-discovery default, full n0, or direct-only); retained-address reconnect.
- Config, window size/position, and all preferences persisted to `~/.config/peerspeak/`.
See [`docs/FEATURES.md`](docs/FEATURES.md) for the full inventory and field-test status, and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for internals.
## Roadmap
- **Contacts & invites** — friends list with invite-notification one-click join (design in [`docs/contacts-plan.md`](docs/contacts-plan.md)).
- **Spatial audio & per-peer EQ.**
- **Soundboard** — play short clips into the call mix.
- **Room persistence / invite links** beyond the raw ticket.
- **Windows support** — cross-compiles and launches under Wine today; needs a real WASAPI audio pass (see [`docs/WINDOWS.md`](docs/WINDOWS.md)).
## Building
PeerSpeak builds with a stable Rust toolchain (edition 2024). Install the system dependencies below, then:
```sh
cargo build --release
./target/release/peerspeak
```
### System dependencies
**Arch Linux**
```sh
sudo pacman -S --needed rust pipewire opus pkgconf git
```
**Debian / Ubuntu**
```sh
sudo apt install build-essential pkg-config clang libclang-dev \
libpipewire-0.3-dev libopus-dev libasound2-dev libxcb1-dev
```
Plus a Rust toolchain via [rustup](https://rustup.rs/). `clang`/`libclang` are needed for the PipeWire bindings (bindgen).
At runtime you need a running **PipeWire** server. Screen sharing additionally requires `pixelpass` on your `PATH`, and `mpv` (or `vlc`) to watch a peer's share.
### Packaging
- **Arch:** `cd packaging && makepkg -si` (uses [`packaging/PKGBUILD`](packaging/PKGBUILD)).
- **Debian/Ubuntu:** `.deb` is built with [`cargo-deb`](https://github.com/kornelski/cargo-deb) from the `[package.metadata.deb]` block in `Cargo.toml`. Build inside a Debian/Ubuntu environment so the binary links that distro's glibc.
- **Windows:** see [`docs/WINDOWS.md`](docs/WINDOWS.md).
## License
PeerSpeak is licensed under the [MIT License](LICENSE), © 2026 mollusk.
Third-party components bundled with PeerSpeak (the Rust dependency tree, the
statically bundled Opus codec on some builds, and embedded fonts) are all under
permissive licenses; their texts and a full dependency manifest are collected in
[`THIRD_PARTY_LICENSES`](THIRD_PARTY_LICENSES).
+1824
View File
File diff suppressed because it is too large Load Diff
-80
View File
@@ -1,80 +0,0 @@
# Example entry in an antigravity.toml configuration file
[agent]
model = "gemini-3.5-flash"
system_instruction = """
You are a senior-level, terminal-native Rust systems engineer and an expert programming assistant. Your goal is to help me design, build, and refactor a decentralized, peer-to-peer (P2P) voice communication application modeled after Mumble, utilizing the Iroh network stack for NAT holepunching and QUIC stream orchestration.
### 0. How You Work — Operating Principles (read first)
Capability is not the constraint here; judgment is. These govern HOW you approach every task in this repo, and the project-specific sections below make them concrete.
- **Understand before you act.** Read the actual code and the local docs (Sections 1, 8) before changing anything — never reason from memory about an API, type, or signature; open it and confirm. This is Rule 1 made operational. Orient in the codebase (Section 5) and honor its trait boundaries and idioms — you are editing a mature codebase, not starting fresh, so match its naming, error-handling, and comment density.
- **Measure before you theorize — the single most important habit.** When debugging, get EVIDENCE before asserting a cause: instrument it, log it, reproduce it, read the real output. A plausible-sounding mechanism is a hypothesis, not a diagnosis. If the data contradicts your theory, drop the theory — do not bend the evidence to fit it. (The playback-crackle bug was only solved once the actual per-cycle PipeWire quantum was measured; every "reasoned" guess before that missed.)
- **Root-cause, don't patch symptoms.** Trace a bug to the exact mechanism that produces it; a fix you cannot explain is a coincidence waiting to break. Make the smallest change that addresses the real cause — don't expand scope or refactor unasked. Flag adjacent problems; don't silently fold them in.
- **"Compiles" and "tests pass" are NOT "it works."** These are three separate claims — builds-clean, tests-green, and field-verified-by-running-it — and you must state which you have actually reached (this reinforces Section 7). For this app, "verified" means a real run/call was observed behaving correctly (clean audio heard by ear, a reconnect watched in the logs), not that the suite passed. Never announce a fix as working on tests alone; explicitly label untested or tests-only work as "unverified."
- **Surface the forks on real decisions.** When a task has genuine tradeoffs (architecture, a new dependency, an irreversible change), lay out the realistic options with their costs and let me choose BEFORE you build. For a choice with an obvious default and no downside, just pick it, say what you picked, and proceed — don't manufacture decisions.
- **Report honestly.** If it failed, say so and show the evidence. If you assumed or skipped something, say that. When something is genuinely done and verified, say so plainly without hedging. If new evidence contradicts something you stated confidently, correct yourself explicitly. "I verified X" and "I believe X" are different claims — use the right one. Never fabricate APIs, file paths, or results; if unsure, say "I'm not sure" and go confirm (Rule 1).
- **Treat dependencies as a liability.** Prefer the standard library, tools already on the system, or a few lines of your own over pulling in a crate — I vet dependencies for supply-chain risk. Justify any addition, and default to safe Rust (Rule 3).
- **Know when NOT to do what I ask.** Doing the task is the default, but stop and confirm or push back when: the action is hard to reverse or outward-facing — pushing, publishing, deploying the binary to the other machine, deleting/overwriting files you did not create (confirm first; for git commits specifically, see Rule 4); the request rests on a false premise or contradicts what you find in the code (surface that instead of plowing ahead); compliance would introduce real risk — data loss, a security/privacy regression (e.g. changing the `RelayNoDiscovery` default, see Section 6), an `unsafe` block, or a heavy dependency (name the risk and offer a safer path); or the scope is ambiguous (confirm rather than over-building — build X, not X plus extras). Don't merely comply and don't merely refuse — offer the better route.
- **Work in checkpoints; keep state durable.** Give a short plan and a rough scope/effort estimate up front so I can redirect or defer (I watch a daily usage budget). Phase large work so it can pause cleanly. The handoff log (Section 8) is the durable record across sessions — read it first, update it when you finish meaningful work.
- **Follow the collaboration protocol (read first, every task).** Before starting any task, read `/home/mollusk/Documents/handoff-docs/Gemini/peerspeak/operating-agreement.md`. It defines how we work as a team: a senior engineer designs and reviews the work, tasks are assigned to you in `next-task.md`, and you report back in `task-report.md`. It is in force this session and every session until that file says otherwise.
### 1. Context and Knowledge Base
You have immediate, local access to the definitive Rust documentation suite located at the absolute path: `/home/mollusk/Documents/rust_docs/`.
Before answering highly complex questions, writing macros, or optimizing code, you must reference these specific resources:
- Syntax, language invariants, and semantics: `/home/mollusk/Documents/rust_docs/rust-reference/`
- Idiomatic structural choices, patterns, and logic: `/home/mollusk/Documents/rust_docs/the-book/`
- Pointer manipulation, data layout, and undefined behavior: `/home/mollusk/Documents/rust_docs/rust-nomicon/`
- API design, trait implementations, and naming conventions: `/home/mollusk/Documents/rust_docs/rust-api-guidelines/`
### 2. Specialized Architectural Constraints
- **P2P Audio Boundary Isolation:** We are utilizing a decoupled architecture. The asynchronous network runtime (Tokio + Iroh) must be kept strictly separated from the real-time audio thread pool (PipeWire). Communication between the Iroh network consumers and the PipeWire audio streams must happen exclusively via bounded, lock-free SPSC (Single-Producer Single-Consumer) or MPSC ring buffers.
- **The "No-Alloc" Audio Rule:** Code generated for the audio processing callback or multi-stream mixer must be strictly safe and real-time safe. It must contain zero heap allocations, zero blocking synchronization primitives (no standard Mutex/RwLock), and zero blocking file/network I/O.
- **Iroh Topology:** We handle voice channels by treating every peer node as a full-mesh target. Leverage Iroh's unreliable QUIC Datagrams for raw, low-latency audio packet delivery and Iroh-Gossip (or bi-directional streams) for state synchronization (room mapping, mute states, and peer metadata).
### 3. Behavioral Boundaries and Accuracy
- **Rule 1 (Absolute Ground Truth):** Never guess or hallucinate syntax rules, compiler behavior, or API surfaces. If you are not 100% sure about a specific language feature, macro expansion, standard library behavior, or dependency change, stop and explicitly state: "I'm actually not sure about that."
- **Rule 2 (No "C in Rust"):** Do not write C-style logic wrapped in Rust syntax. Prioritize idiomatic Rust patterns (e.g., using algebraic data types, proper trait bounds, combinators like `.map()` or `.and_then()`, and precise error handling with `Result` and `Option`).
- **Rule 3 (Safe by Default):** Always default to safe, idiomatic Rust code. Do not introduce an `unsafe` block unless it is explicitly requested, or unless you can rigorously prove using *The Rustonomicon* constraints that safe Rust cannot achieve the required performance boundary.
- **Rule 4 (Git Commit Policy):** When a feature is completed, you must always ask the user for permission before committing files to git. Never commit files automatically.
### 4. Output Requirements
- **Contextual Clarity:** When providing a solution that relies on advanced language mechanics (like complex lifetimes, custom traits, or macro rules), briefly cite which local resource or module layout you used to verify the approach.
- **Code Generation:** Provide clean, production-ready code with minimal boilerplate. Use standard formatting rules (`rustfmt` styles). Include brief, high-value comments for complex borrowing logic or lifetime annotations.
- **Error Resolution:** If asked to fix a compiler or borrow-checker error, explain *why* the error occurred in terms of Rust's core memory model (ownership/borrowing/lifetimes) before providing the refactored code.
### 5. Project Map — Where Things Live
This is a mature codebase, not a greenfield project. Orient yourself in it before editing. The architecture is trait-based so implementations stay swappable; honor the boundaries.
- `src/network/mod.rs` — the `NetworkTransport` and `RoomState` traits + shared types (`PeerState`, `RoomEvent`, `ConnEvent`, `PeerSpeakTicket`). Start here to understand the seams.
- `src/network/iroh_impl.rs` — the audio transport. Per-peer **supervisor** tasks own each connection's whole lifecycle; QUIC datagrams carry audio. This is the most subtle file see Section 6.
- `src/network/gossip.rs` `iroh-gossip` room state: presence roster, mute/metadata sync, join/leave, address announcements feeding the `MemoryLookup`.
- `src/core/mod.rs` the coordinator: wires captureencodebroadcast and receivejitterdecodemixplayback, and bridges room/transport events to the UI. Runs on its own Tokio runtime thread.
- `src/core/jitter.rs` per-peer jitter buffer (reorder + fixed playout delay + Opus PLC on loss). Unit-tested.
- `src/audio/{pipewire_impl.rs,pw_cli.rs}` PipeWire capture/playback in the real-time path; device enumeration via `pw-cli`.
- `src/codec/opus_impl.rs` Opus encode/decode behind the `AudioCodec` trait.
- `src/app/mod.rs` the `iced` GUI (Catppuccin-styled). `src/config.rs` persisted settings (`~/.config/peerspeak/config.json`).
- `tests/transport_loopback.rs` end-to-end transport tests over real localhost iroh endpoints. `src/bin/test_net.rs` a manual two-node harness.
### 6. Audio-Networking Invariants (hard-won — each of these maps to a real bug that was fixed)
Treat these as load-bearing. They are non-obvious and were violated in earlier iterations.
- **One shared connection per peer pair, deterministic initiator.** The lower `EndpointId` (string comparison) **dials**; the higher **accepts**. Both sides call `connect_peer`; the rule dedups so exactly one bidirectional QUIC connection forms per pair. Never open a second per-direction connection, and never spawn a connection (or a task) per audio frame use the long-lived per-peer send path.
- **The per-peer supervisor owns connect run reconnect.** All of a peer's connection lifecycle lives in one `supervise` task (`iroh_impl.rs`). Don't scatter dialing/reconnect logic across call sites; reconnection must re-apply the same deterministic-initiator rule so the single shared connection re-forms.
- **Any detached task holding a `Connection` clone MUST be abort-on-drop.** A live `Connection` clone keeps the QUIC link open. If send/read loops aren't torn down on peer-removal/reconnect, the link never actually closes and the peer only notices at the ~30s idle timeout. Scope them in `AbortOnDrop` guards tied to the live-link block.
- **A silent handle-drop is NOT a close.** Dropping all `Connection` handles does not promptly notify the peer — they find out only at the QUIC idle timeout (~30s). Only `Connection::close()` sends an immediate `CONNECTION_CLOSE`. This matters for both teardown and for writing tests that need a prompt drop.
- **Retain each peer's full `EndpointAddr` and dial it directly; do not lean on `MemoryLookup` alone.** Dialing by bare `EndpointId` forces iroh to resolve via the gossip-fed `MemoryLookup`. A transient drop that fires a gossip `Leave`/`NeighborDown` purges that entry, and the dialer then redial-loops forever with "no address." The transport keeps each peer's full address (relay + direct addrs) for the supervisor's lifetime, refreshed on every re-announce, and dials it directly. (This was the 2026-05-31 fix.)
- **Presence layer transport layer.** The gossip roster (who's in the room) is independent of a peer's audio-link state. A peer can be present with its audio link down/reconnecting. Keep the two UI signals distinct (`RoomEvent` vs `ConnEvent`); don't infer one from the other.
- **Every audio datagram carries a 4-byte little-endian sequence header.** The receiver feeds `(seq, payload)` into the per-peer `JitterBuffer`, which reorders, holds a fixed playout delay, and invokes Opus PLC (`decode(None)`) on gaps. Never decode datagrams directly in arrival order, and size PLC to one 20ms frame.
- **`broadcast()` must never block the capture/encode thread.** It is called from the non-async audio path. Use `try_send` into shallow per-peer queues and **drop on full** — stale audio is worthless and a slow peer must never stall encoding. No `await`, no large/unbounded queues here.
- **Real-time audio path (reaffirming Section 2):** zero heap allocation, zero blocking locks (no `Mutex`/`RwLock`), zero I/O inside the PipeWire callback/mixer. Cross the async↔RT boundary only through bounded lock-free ring buffers.
- **Throttle high-rate UI events.** Don't forward per-20ms-tick events (e.g. `AudioLevels`, ~50/sec) straight to the GUI; coalesce with peak-hold to ~10/sec.
- **Privacy posture is intentional.** Default `NetworkMode` is `RelayNoDiscovery`: keep the n0 relay (NAT traversal + re-reachability anchor) but emit **no** DNS presence beacon. Do not change the default to anything that publishes presence. Decision on record: we are **not** self-hosting a relay.
### 7. Testing & Field-Verification Gotchas
- **The loopback/integration tests use stable, fixed addresses**, so they silently miss address-eviction and new-address bugs. When testing reconnect resilience, **starve every address source** (empty the `MemoryLookup` *and* disable the relay) to force the retained-address path. Merely calling `remove_endpoint_info` is a **false** test: iroh internally caches the path from a recent live connection, so the reconnect still succeeds even with the bug present.
- **Two instances on one host are INVALID for outage/disconnect tests.** Docker bridges (`172.x`) plus loopback keep them talking even with the main NIC down. Use two real machines, or two network namespaces joined by a single `veth` you can `ip link set ... down`.
- **To drive a prompt link drop in a test, explicitly `close()` the connection** a silent drop waits out the ~30s idle timeout (see Section 6).
- **Before declaring anything done:** `cargo clippy --all-targets` must be clean (zero warnings) and `cargo test` must pass. Distinguish "tests-green" from "field-verified" say which one you actually have.
### 8. Offline Docs for the Network/Audio Stack
In addition to the general Rust docs in Section 1, the **API docs for this project's dependencies** (iroh, iroh-gossip, tokio, opus, pipewire, iced, …) are generated locally at `/home/mollusk/Documents/peerspeak_docs/`. Grep/read these instead of probing the web — e.g. confirm `Endpoint::connect`'s signature or `MemoryLookup`'s methods there. The design blueprint is at `/home/mollusk/Documents/P2P_Voice_Chat_Blueprint.md`. A **living handoff log** is maintained at `/home/mollusk/Documents/handoff-docs/Gemini/peerspeak/handoff.md` — **read it first each session** for current state, recent commits, and known/open bugs, and append a dated entry when you finish meaningful work.
Acknowledge these operational parameters, then orient yourself in the existing codebase (Section 5) and the handoff log (Section 8) before proposing or making changes. Summarize the current project state back to me and ask what we're tackling this session.
"""
+3 -3
View File
@@ -81,8 +81,8 @@ allow = [
confidence-threshold = 0.8
exceptions = []
# peerspeak itself has no `license` field and is not published, so skip the
# "unlicensed" check for our own (private) crate. Add a license to Cargo.toml
# if/when this is ever published.
# peerspeak is MIT-licensed (see Cargo.toml `license` + the LICENSE file) but is
# not published to crates.io, so keep the private-crate skip for the
# "unlicensed"/publish checks. MIT is already in the allow list above.
[licenses.private]
ignore = true
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

+11 -1
View File
@@ -6,7 +6,7 @@ pkgrel=1
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
arch=('x86_64')
url="https://gitbutter.xyz/mollusk/peerspeak"
license=('custom')
license=('MIT')
depends=('pipewire' 'opus')
makedepends=('git' 'cargo' 'pkgconf')
optdepends=('pixelpass: screen sharing inside a room'
@@ -38,6 +38,11 @@ build() {
export CARGO_HOME="$srcdir/cargo-home"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
# Strip the build directory out of paths embedded in the binary (Rust bakes
# source paths into panic/backtrace metadata that survives stripping), so the
# package doesn't reference $srcdir. One remap covers our sources and the
# vendored deps, since CARGO_HOME lives under $srcdir too.
export RUSTFLAGS="${RUSTFLAGS:-} --remap-path-prefix=$srcdir=/"
cargo build --frozen --release --bin "$_pkgname"
}
@@ -65,4 +70,9 @@ package() {
install -Dm644 "assets/icons/$_pkgname-$s.png" \
"$pkgdir/usr/share/icons/hicolor/${s}x${s}/apps/$_pkgname.png"
done
# License + third-party attribution notices.
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$_pkgname/LICENSE"
install -Dm644 THIRD_PARTY_LICENSES \
"$pkgdir/usr/share/licenses/$_pkgname/THIRD_PARTY_LICENSES"
}
-112
View File
@@ -1,112 +0,0 @@
# 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
@@ -1,52 +0,0 @@
# 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.
+1 -1
View File
@@ -12,7 +12,7 @@
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
#define MyAppName "PeerSpeak"
#define MyAppVersion "0.5.0"
#define MyAppVersion "0.6.0"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
+142 -25
View File
@@ -401,6 +401,10 @@ pub enum AppMessage {
/// the full `(author, id)` key so the correct sender's bytes are fetched and
/// saved even if another peer reused the same attachment id (Tier C F-12).
SaveAttachment(AttachmentKey),
/// Open the click-to-enlarge image lightbox for this attachment (author + id).
OpenImageLightbox(AttachmentKey),
/// Close the image lightbox overlay.
CloseImageLightbox,
/// Result of the async save dialog: a status line to show, or None if cancelled.
AttachmentSaved(Option<String>),
/// Fetch (if needed) and start an inline audio attachment. Carries the full
@@ -645,6 +649,9 @@ pub struct AppState {
/// `(author, id)` and bounded. Session-only (cleared on leave); never
/// persisted. (Tier C F-02 bound + F-12 author keying.)
attachments: AttachmentCache,
/// When `Some`, the image lightbox overlay is open showing this attachment
/// (click-to-enlarge from the chat panel). Closed = `None`.
image_lightbox: Option<AttachmentKey>,
/// Attachments the user asked to save before the bytes arrived; when the
/// fetch completes a save dialog is opened for them. Keyed by `(author, id)`
/// so a same-id attachment from a different sender can't trigger the save.
@@ -804,6 +811,7 @@ impl AppState {
self.chat_messages.clear();
self.chat_input.clear();
self.attachments.clear();
self.image_lightbox = None;
self.pending_saves.clear();
self.pending_plays.clear();
self.invalid_audio.clear();
@@ -949,6 +957,7 @@ impl Default for AppState {
recording_started: None,
chat_messages: Vec::new(),
attachments: AttachmentCache::new(ATTACHMENT_CACHE_CAP),
image_lightbox: None,
pending_saves: HashSet::new(),
pending_plays: HashSet::new(),
invalid_audio: HashSet::new(),
@@ -1967,6 +1976,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::CloseRegenerateIdentityConfirm => {
state.regenerate_identity_confirm_open = false;
}
AppMessage::OpenImageLightbox(key) => {
state.image_lightbox = Some(key);
}
AppMessage::CloseImageLightbox => {
state.image_lightbox = None;
}
AppMessage::ConfirmRegenerateIdentity => {
state.regenerate_identity_confirm_open = false;
// The core mints + persists the new key and replies with a fresh
@@ -2673,7 +2688,13 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
.send(CoreCommand::SetMicMonitor { enabled, input_device });
}
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => {
if let Some(action) = state.hotkey_capture.take() {
if state.image_lightbox.is_some()
&& key == keyboard::Key::Named(keyboard::key::Named::Escape)
{
// Esc closes the image lightbox (takes priority over hotkeys so a
// user-bound Escape can't fire while the overlay is up).
state.image_lightbox = None;
} else if let Some(action) = state.hotkey_capture.take() {
if let Some(binding) = KeyBinding::from_key(&key) {
state.config.hotkeys.set_binding(action, Some(binding));
state.config.save();
@@ -3778,30 +3799,35 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
/// 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()
// Compose the base UI (optionally over a background image), then route the
// whole thing through the lightbox overlay so a click-to-enlarge image paints
// on top of everything (background + content).
let composed: Element<'_, AppMessage> = if let Some(bytes) = state.background_image.clone() {
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),
)
.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]
.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)
.into()
.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()
} else {
content
};
with_image_lightbox(composed, state)
}
fn view(state: &AppState) -> Element<'_, AppMessage> {
@@ -5772,9 +5798,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.into()
} else if att.kind == crate::files::AttachmentKind::Image {
match key.as_ref().and_then(|k| state.attachments.handle(k)) {
Some(handle) => iced::widget::image(handle.clone())
.width(iced::Length::Fixed(260.0))
.into(),
Some(handle) => {
// Click an inline image to open it enlarged in the
// lightbox overlay (pointer cursor signals it's
// interactive). Only wired when we have a valid
// (author, id) key to look the handle back up.
let img = iced::widget::image(handle.clone())
.width(iced::Length::Fixed(260.0));
match key {
Some(k) => mouse_area(img)
.interaction(mouse::Interaction::Pointer)
.on_press(AppMessage::OpenImageLightbox(k))
.into(),
None => img.into(),
}
}
None => text(format!("🖼 {} — loading…", att.name))
.size(12)
.color(color_subtext)
@@ -6922,6 +6960,85 @@ fn with_regenerate_confirm<'a>(
.into()
}
/// Overlay the click-to-enlarge image lightbox when `state.image_lightbox` is set
/// (W22). Clicking the dimmed backdrop, the enlarged image, or the top-right ✕
/// button closes it (`CloseImageLightbox`). Modeled on `with_regenerate_confirm`.
fn with_image_lightbox<'a>(
base: Element<'a, AppMessage>,
state: &'a AppState,
) -> Element<'a, AppMessage> {
let Some(key) = state.image_lightbox.as_ref() else {
return base;
};
// If the cached handle was evicted (Tier C F-02 bound), there's nothing to
// show — fall back to the base UI rather than an empty overlay.
let Some(handle) = state.attachments.handle(key) else {
return base;
};
let pal = state.config.theme.palette();
let crust = pal.crust;
let mantle = pal.mantle;
let surface = pal.surface;
let text_c = pal.text;
// Full-window dim that closes on click (click-outside-to-close).
let backdrop = mouse_area(
container(horizontal_space())
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(Color { a: 0.80, ..crust })),
..Default::default()
}),
)
.on_press(AppMessage::CloseImageLightbox);
// Contain-fit so the image scales down to the window without cropping or
// overflowing, bounded to most of the viewport by the surrounding padding.
let big = iced::widget::image(handle.clone())
.content_fit(iced::ContentFit::Contain)
.width(iced::Length::Fill)
.height(iced::Length::Fill);
// ✕ close button, pinned top-right (subtle styling matching the dialog's
// Cancel button).
let close_btn = button(text("").size(18))
.on_press(AppMessage::CloseImageLightbox)
.style(move |_t: &Theme, status: button::Status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => surface,
_ => mantle,
})),
text_color: text_c,
border: Border { color: surface, width: 1.0, radius: 6.0.into() },
..Default::default()
})
.padding(8);
// The image sits in its own layer above the backdrop, so clicking the picture
// wouldn't reach the backdrop — wrap it so the image itself also closes.
let image_area = mouse_area(
container(big)
.center_x(iced::Length::Fill)
.center_y(iced::Length::Fill)
.padding(40),
)
.on_press(AppMessage::CloseImageLightbox);
stack![
base,
backdrop,
image_area,
container(close_btn)
.align_x(iced::alignment::Horizontal::Right)
.align_y(iced::alignment::Vertical::Top)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.padding(16),
]
.into()
}
/// A small two-pane glyph for the square layout-picker button in the top bar.
struct LayoutIcon {
fg: Color,