Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e1740d3c |
@@ -1,59 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to PeerSpeak are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Friends list now reflects status changes without a restart.** A presence probe that fails now actively marks the friend **offline**, so a friend who goes offline, leaves a room, or turns invisible no longer lingers showing a stale "online" / "in a room" status until PeerSpeak is relaunched. Previously only successful probes updated the list, so it could ratchet a friend's status up but never down. The auto-refresh interval was also shortened from 60s to **15s** so the list tracks changes more closely.
|
||||
|
||||
### Added
|
||||
- **Manual "⟳ Rescan" button** on the Friends panel that refreshes everyone's presence immediately, instead of waiting for the next auto-refresh.
|
||||
|
||||
### 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
|
||||
- **Shared music listening (W22).** A new **Playlist** panel lets you build a personal queue of local audio files and play them on a dedicated music player — Browse to add tracks, play/pause, previous/next, seek, per-track reorder, remove, and a local volume slider, all persisted across sessions. `.pls` and `.m3u` playlists can be imported (remote and non-audio entries are skipped).
|
||||
- **Tune in to a friend's music.** Flip **"Let others tune in"** and peers see your current track under the **Public** tab; one click on **Listen** streams it to them. Playback is **timeline-synced** — play, pause, skip, and seek mirror across everyone with no drift — and the next track is **prefetched for gapless** transitions. Each listener gets an independent **per-source volume**, so music sits under voice at whatever level they like; voice chat stays fully audible throughout.
|
||||
- **Standalone Playlist card in the 3-Column layout.** The playlist now lives in its own card stacked under the chat, with a draggable divider to resize it and its own scrollbar when space is tight. The other layouts keep the playlist in the Controls panel.
|
||||
|
||||
### Security
|
||||
- Shared-music metadata is treated as untrusted: the broadcast track name is sanitized and its size is cap-checked at gossip ingest, fetched bytes are confirmed to be audio before decoding, and only a small descriptor ever rides gossip — track bytes move point-to-point over the existing files plane, one fetch in flight at a time.
|
||||
|
||||
### Changed
|
||||
- **Wire protocol bump (gossip v5).** Shared listening adds presence fields, so **0.6.0 peers cannot share a swarm with 0.5.x peers** — everyone in a room must update together.
|
||||
|
||||
[0.6.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.0
|
||||
|
||||
## [0.5.1] — 2026-06-27
|
||||
|
||||
### Added
|
||||
- **Volume control for inline chat audio clips.** A master volume slider plus a **Universal volume** toggle now sit in the Chat panel header, and every audio attachment gets its own 🔊 slider on its play row. With Universal volume on (the default), one level applies to all clips and persists across sessions; turn it off to give each clip its own independent level.
|
||||
|
||||
## [0.5.0] — 2026-06-27
|
||||
|
||||
### Added
|
||||
- **Right-click context menu** (Cut / Copy / Paste / Select All) on every text-entry field. (A9)
|
||||
- **Selectable, copyable text** for values that used to be read-only: your full node ID and the room ticket can now be click-selected and copied, and **chat messages are drag-selectable** (highlight + Ctrl+C / Ctrl+A) while clickable links keep working. (W21 Phase 1 + 2)
|
||||
- **Clock-skew warning**: when a peer can't be seen because the two systems' clocks differ by more than the replay-protection window, PeerSpeak now shows a "your clocks are out of sync" banner instead of failing silently. (A25)
|
||||
- **Per-application audio capture for screen-share**, removing the call-audio loopback echo when sharing a window, plus surfacing of pixelpass startup errors. (A23)
|
||||
|
||||
### Changed / Fixed
|
||||
- **Critical commands are now delivered reliably under load** — muting, releasing push-to-talk, and leaving a room can no longer be silently dropped while a slider is being dragged (prevents a hot-mic state mismatch). (A15)
|
||||
- Screen-share now passes `--strict-audio` and surfaces app-audio drop warnings; the source picker state machine and pactl handling were hardened. (A23)
|
||||
- Per-peer volume control path verified and covered by regression tests. (A24)
|
||||
|
||||
### Security
|
||||
- Hardened against insider resource-exhaustion: bounded + author-keyed chat attachment cache, capped recovery-identity state, and other Tier-C caps (F-01 / F-02 / F-03 / F-12).
|
||||
|
||||
### Packaging
|
||||
- Added Debian/Ubuntu `.deb` packaging (cargo-deb metadata); the official `.deb` is now built on **Debian 12 (bookworm)** for wide compatibility.
|
||||
- Arch `PKGBUILD` clones over anonymous HTTPS.
|
||||
|
||||
[0.5.1]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.1
|
||||
[0.5.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.0
|
||||
|
||||
Earlier releases: see git tags `v0.4.0`, `v0.3.0`, `v0.2.0`.
|
||||
Generated
+1
-1
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.6.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+5
-7
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "peerspeak"
|
||||
version = "0.6.0"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
license = "MIT"
|
||||
# Application crate, not published to crates.io — refuse `cargo publish`.
|
||||
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
||||
# cargo-deny's [licenses.private] skip the missing-license check.
|
||||
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. MIT License."
|
||||
copyright = "2026, mollusk. Private build — not for redistribution."
|
||||
section = "net"
|
||||
priority = "optional"
|
||||
depends = "$auto"
|
||||
@@ -33,8 +33,6 @@ 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]
|
||||
@@ -59,7 +57,7 @@ async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
bytes = "1.11.1"
|
||||
dirs = "6.0.0"
|
||||
iced = { version = "0.14.0", features = ["advanced", "canvas", "image", "tokio"] }
|
||||
iced = { version = "0.14.0", features = ["canvas", "image", "tokio"] }
|
||||
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
|
||||
# the codec surface small). The matching native file picker (`rfd`) is platform-
|
||||
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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.
|
||||
@@ -1,115 +0,0 @@
|
||||
<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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Settings |
|
||||
|---|
|
||||
|  |
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
# 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 capture→encode→broadcast and receive→jitter→decode→mix→playback, 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.
|
||||
"""
|
||||
@@ -81,8 +81,8 @@ allow = [
|
||||
confidence-threshold = 0.8
|
||||
exceptions = []
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
[licenses.private]
|
||||
ignore = true
|
||||
|
||||
@@ -225,6 +225,20 @@ state change; rate-limit pings), tickets from friends (validate defensively, no
|
||||
auto-join), the discovery publish (only when toggled, ideally auto-expiring).
|
||||
`cargo audit` (JSON store → no new deps expected). Field test on dopedart.
|
||||
|
||||
**Local hardening DONE 2026-06-27:** inbound friend-presence replies are now
|
||||
rate-limited per authenticated friend id (`PresenceRateLimiter`: burst 4, refill
|
||||
1/15s) and wired into the live friends listener before it builds a `Pong`; denied
|
||||
probes get the same silent no-data close as unauthorized probes. Existing
|
||||
defensive reply handling still validates room tickets against the authenticated
|
||||
friend id and never auto-joins. Verified with `cargo test presence`,
|
||||
`cargo test --lib`, `cargo clippy --all-targets -- -D warnings`, and
|
||||
`cargo audit --no-fetch --stale` (local DB; reports only the two already-allowed
|
||||
unmaintained advisories in `deny.toml`). A fresh advisory fetch was blocked in
|
||||
this sandbox by network restrictions.
|
||||
|
||||
**Remaining:** live 2-machine field test on dopedart, a fresh online
|
||||
`cargo audit`, and any follow-up findings from that test.
|
||||
|
||||
## The connect flow (the user's scenario, end to end)
|
||||
1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled
|
||||
"HangOut."
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 139 KiB |
+3
-13
@@ -1,12 +1,12 @@
|
||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||
pkgname=peerspeak-git
|
||||
_pkgname=peerspeak
|
||||
pkgver=0.5.0.r0.g0000000
|
||||
pkgver=0.4.0.r254.g913b0b6
|
||||
pkgrel=1
|
||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
arch=('x86_64')
|
||||
url="https://gitbutter.xyz/mollusk/peerspeak"
|
||||
license=('MIT')
|
||||
license=('custom')
|
||||
depends=('pipewire' 'opus')
|
||||
makedepends=('git' 'cargo' 'pkgconf')
|
||||
optdepends=('pixelpass: screen sharing inside a room'
|
||||
@@ -14,7 +14,7 @@ optdepends=('pixelpass: screen sharing inside a room'
|
||||
provides=('peerspeak')
|
||||
conflicts=('peerspeak')
|
||||
options=('!lto' '!debug')
|
||||
source=("$_pkgname::git+https://gitbutter.xyz/mollusk/peerspeak.git")
|
||||
source=("$_pkgname::git+ssh://git@gitbutter.xyz/mollusk/peerspeak.git")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
pkgver() {
|
||||
@@ -38,11 +38,6 @@ 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"
|
||||
}
|
||||
|
||||
@@ -70,9 +65,4 @@ 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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -12,7 +12,7 @@
|
||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||
|
||||
#define MyAppName "PeerSpeak"
|
||||
#define MyAppVersion "0.6.0"
|
||||
#define MyAppVersion "0.4.0"
|
||||
#define MyAppPublisher "mollusk"
|
||||
#define MyAppExeName "peerspeak.exe"
|
||||
|
||||
|
||||
+66
-1592
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,6 @@ enum ClipCommand {
|
||||
Resume,
|
||||
Seek(Duration),
|
||||
Stop,
|
||||
SetVolume(f32),
|
||||
}
|
||||
|
||||
/// Cheap, `Send` command handle for the dedicated playback thread.
|
||||
@@ -52,16 +51,13 @@ pub struct ClipPlayer {
|
||||
impl ClipPlayer {
|
||||
/// Start the playback worker. The system output device is opened lazily on
|
||||
/// first Play, so merely launching PeerSpeak never claims another stream.
|
||||
///
|
||||
/// `initial_volume` is the universal gain (`1.0` = unity) applied to every
|
||||
/// clip, restored from config so the level persists across sessions.
|
||||
pub fn new(initial_volume: f32) -> (Self, SharedClipStatus) {
|
||||
pub fn new() -> (Self, SharedClipStatus) {
|
||||
let (command_tx, command_rx) = mpsc::channel();
|
||||
let status = Arc::new(Mutex::new(ClipStatus::default()));
|
||||
let worker_status = Arc::clone(&status);
|
||||
std::thread::Builder::new()
|
||||
.name("peerspeak-clip-player".to_string())
|
||||
.spawn(move || playback_worker(command_rx, worker_status, initial_volume))
|
||||
.spawn(move || playback_worker(command_rx, worker_status))
|
||||
.expect("failed to spawn clip playback thread");
|
||||
(
|
||||
Self {
|
||||
@@ -98,24 +94,11 @@ impl ClipPlayer {
|
||||
pub fn stop(&self) {
|
||||
let _ = self.command_tx.send(ClipCommand::Stop);
|
||||
}
|
||||
|
||||
/// Set the universal playback gain (`1.0` = unity). Applies to the current
|
||||
/// clip immediately and to every clip played afterwards.
|
||||
pub fn set_volume(&self, volume: f32) {
|
||||
let _ = self.command_tx.send(ClipCommand::SetVolume(volume));
|
||||
}
|
||||
}
|
||||
|
||||
fn playback_worker(
|
||||
command_rx: mpsc::Receiver<ClipCommand>,
|
||||
status: SharedClipStatus,
|
||||
initial_volume: f32,
|
||||
) {
|
||||
fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipStatus) {
|
||||
let mut output: Option<MixerDeviceSink> = None;
|
||||
let mut player: Option<Player> = None;
|
||||
// Universal gain remembered across clips so a level set on one upload
|
||||
// carries to the next; reapplied to each freshly connected player.
|
||||
let mut volume = initial_volume.max(0.0);
|
||||
|
||||
loop {
|
||||
match command_rx.recv_timeout(Duration::from_millis(100)) {
|
||||
@@ -141,9 +124,7 @@ fn playback_worker(
|
||||
if output.is_none() {
|
||||
match DeviceSinkBuilder::open_default_sink() {
|
||||
Ok(sink) => {
|
||||
let new_player = Player::connect_new(sink.mixer());
|
||||
new_player.set_volume(volume);
|
||||
player = Some(new_player);
|
||||
player = Some(Player::connect_new(sink.mixer()));
|
||||
output = Some(sink);
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -196,12 +177,6 @@ fn playback_worker(
|
||||
}
|
||||
reset(&status);
|
||||
}
|
||||
Ok(ClipCommand::SetVolume(level)) => {
|
||||
volume = level.max(0.0);
|
||||
if let Some(player) = player.as_ref() {
|
||||
player.set_volume(volume);
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
sharing: None,
|
||||
avatar: Default::default(),
|
||||
game: None,
|
||||
music: None,
|
||||
};
|
||||
room_a.join(&ticket_str, state_a, vec![]).await?;
|
||||
println!("Node A joined topic.");
|
||||
@@ -86,7 +85,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
sharing: None,
|
||||
avatar: Default::default(),
|
||||
game: None,
|
||||
music: None,
|
||||
};
|
||||
room_b.join(&ticket_str, state_b, vec![]).await?;
|
||||
println!("Node B joined topic.");
|
||||
|
||||
@@ -126,10 +126,6 @@ fn default_chat_height() -> f32 {
|
||||
180.0
|
||||
}
|
||||
|
||||
fn default_threecol_playlist_height() -> f32 {
|
||||
220.0
|
||||
}
|
||||
|
||||
fn default_controls_width() -> f32 {
|
||||
280.0
|
||||
}
|
||||
@@ -160,24 +156,6 @@ pub struct AppConfig {
|
||||
/// App-internal playback gain applied to the mixed output (1.0 = unity).
|
||||
#[serde(default = "default_volume")]
|
||||
pub output_volume: f32,
|
||||
/// Universal playback gain for inline chat audio clips (1.0 = unity). One
|
||||
/// level shared by every uploaded clip so the slider sticks across plays.
|
||||
#[serde(default = "default_volume")]
|
||||
pub clip_volume: f32,
|
||||
/// W22 music: the user's personal playlist as local file PATHS (not bytes).
|
||||
/// Loaded into memory at startup; missing files are skipped/marked on play.
|
||||
#[serde(default)]
|
||||
pub music_playlist: Vec<String>,
|
||||
/// W22 music: local playback gain for the dedicated music player (1.0 = unity).
|
||||
#[serde(default = "default_volume")]
|
||||
pub music_volume: f32,
|
||||
/// W22 music: opt-in shared listening broadcast toggle. Local preference.
|
||||
#[serde(default)]
|
||||
pub music_broadcast: bool,
|
||||
/// When true, `clip_volume` governs every clip. When false, each clip keeps
|
||||
/// its own (in-memory) level and the universal slider is inactive.
|
||||
#[serde(default = "default_true")]
|
||||
pub clip_volume_universal: bool,
|
||||
#[serde(default)]
|
||||
pub network_mode: NetworkMode,
|
||||
/// Presence posture for the friends idle listener (W7): invisible / normal /
|
||||
@@ -196,11 +174,6 @@ pub struct AppConfig {
|
||||
pub participants_width: f32,
|
||||
#[serde(default = "default_chat_height")]
|
||||
pub chat_height: f32,
|
||||
/// Height (px) of the standalone Playlist card stacked under Chat in the
|
||||
/// 3-column layout. Resized via its own horizontal divider; re-clamped to the
|
||||
/// window on load/resize. Only used by `RoomLayout::ThreeColumn`.
|
||||
#[serde(default = "default_threecol_playlist_height")]
|
||||
pub threecol_playlist_height: f32,
|
||||
/// Controls panel width for the 3-column layout (px).
|
||||
#[serde(default = "default_controls_width")]
|
||||
pub controls_width: f32,
|
||||
@@ -306,11 +279,6 @@ pub struct AppConfig {
|
||||
/// string. Local preference only; never sent to peers. Absent entry = unity.
|
||||
#[serde(default)]
|
||||
pub peer_volume: HashMap<String, f32>,
|
||||
/// Per-source music listen volume/gain (`1.0` = unity), keyed by peer node id
|
||||
/// string. Local preference only; never sent to peers. Absent entry falls
|
||||
/// back to `music_volume`.
|
||||
#[serde(default)]
|
||||
pub music_source_volume: HashMap<String, f32>,
|
||||
/// Per-peer listener-side noise-gate threshold (normalized RMS, `0.0` = off),
|
||||
/// keyed by peer node id string. Local preference only; never sent to peers.
|
||||
/// Absent entry = gate disabled (pass-through).
|
||||
@@ -344,18 +312,12 @@ impl Default for AppConfig {
|
||||
noise_gate_threshold: 0.01,
|
||||
input_volume: 1.0,
|
||||
output_volume: 1.0,
|
||||
clip_volume: 1.0,
|
||||
music_playlist: Vec::new(),
|
||||
music_volume: 1.0,
|
||||
music_broadcast: false,
|
||||
clip_volume_universal: true,
|
||||
network_mode: NetworkMode::default(),
|
||||
presence_mode: crate::presence::PresenceMode::default(),
|
||||
echo_cancellation_enabled: false,
|
||||
notifications_enabled: true,
|
||||
participants_width: default_participants_width(),
|
||||
chat_height: default_chat_height(),
|
||||
threecol_playlist_height: default_threecol_playlist_height(),
|
||||
controls_width: default_controls_width(),
|
||||
chat_drawer_width: default_chat_drawer_width(),
|
||||
room_layout: RoomLayout::default(),
|
||||
@@ -388,7 +350,6 @@ impl Default for AppConfig {
|
||||
peer_eq: HashMap::new(),
|
||||
peer_pan: HashMap::new(),
|
||||
peer_volume: HashMap::new(),
|
||||
music_source_volume: HashMap::new(),
|
||||
peer_gate: HashMap::new(),
|
||||
hotkeys: crate::hotkeys::HotkeyMap::default(),
|
||||
window_width: default_window_width(),
|
||||
@@ -545,7 +506,6 @@ mod tests {
|
||||
assert!(deserialized.peer_eq.is_empty());
|
||||
assert!(deserialized.peer_pan.is_empty());
|
||||
assert!(deserialized.peer_volume.is_empty());
|
||||
assert!(deserialized.music_source_volume.is_empty());
|
||||
assert!(deserialized.peer_gate.is_empty());
|
||||
assert_eq!(
|
||||
crate::hotkeys::format_binding(
|
||||
@@ -697,44 +657,23 @@ mod tests {
|
||||
let def = AppConfig::default();
|
||||
assert_eq!(def.input_volume, 1.0);
|
||||
assert_eq!(def.output_volume, 1.0);
|
||||
assert_eq!(def.clip_volume, 1.0);
|
||||
assert!(def.music_playlist.is_empty());
|
||||
assert_eq!(def.music_volume, 1.0);
|
||||
assert!(!def.music_broadcast);
|
||||
assert!(def.clip_volume_universal);
|
||||
|
||||
// Missing in JSON → unity (serde default).
|
||||
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
||||
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
|
||||
assert_eq!(cfg_missing.input_volume, 1.0);
|
||||
assert_eq!(cfg_missing.output_volume, 1.0);
|
||||
assert_eq!(cfg_missing.clip_volume, 1.0);
|
||||
assert!(cfg_missing.music_playlist.is_empty());
|
||||
assert_eq!(cfg_missing.music_volume, 1.0);
|
||||
assert!(!cfg_missing.music_broadcast);
|
||||
// Configs predating the toggle default to universal mode.
|
||||
assert!(cfg_missing.clip_volume_universal);
|
||||
|
||||
// Explicit non-unity values are preserved across a round-trip.
|
||||
let cfg = AppConfig {
|
||||
input_volume: 1.5,
|
||||
output_volume: 0.25,
|
||||
clip_volume: 0.7,
|
||||
music_playlist: vec!["/tmp/song.ogg".to_string()],
|
||||
music_volume: 0.6,
|
||||
music_broadcast: true,
|
||||
clip_volume_universal: false,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let round_tripped: AppConfig =
|
||||
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
||||
assert_eq!(round_tripped.input_volume, 1.5);
|
||||
assert_eq!(round_tripped.output_volume, 0.25);
|
||||
assert_eq!(round_tripped.clip_volume, 0.7);
|
||||
assert_eq!(round_tripped.music_playlist, vec!["/tmp/song.ogg".to_string()]);
|
||||
assert_eq!(round_tripped.music_volume, 0.6);
|
||||
assert!(round_tripped.music_broadcast);
|
||||
assert!(!round_tripped.clip_volume_universal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -61,17 +61,6 @@ pub enum CoreCommand {
|
||||
/// (used for on-demand file/chip downloads; images are auto-fetched on
|
||||
/// receipt). Replies with `AttachmentReady`/`AttachmentFailed`.
|
||||
FetchAttachment { from: EndpointId, attachment: crate::files::ChatAttachment },
|
||||
/// Register `data` as fetchable under `id` for room members (the current
|
||||
/// broadcast track). Called once per track when broadcasting.
|
||||
ServeMusicTrack { id: crate::files::AttachmentId, data: std::sync::Arc<Vec<u8>> },
|
||||
/// Drop a music blob that is no longer current-or-next.
|
||||
ForgetMusicTrack(crate::files::AttachmentId),
|
||||
/// Set (or clear) our broadcast music timeline and re-announce presence.
|
||||
SetMusicPresence(Option<crate::network::MusicPresence>),
|
||||
/// Fetch a source peer's current track bytes after tuning into them.
|
||||
FetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 },
|
||||
/// Fetch a source peer's advertised next track bytes before it becomes current.
|
||||
PrefetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 },
|
||||
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
||||
/// Sent at startup so screen-share can resolve the binary.
|
||||
SetPixelpassPath(Option<String>),
|
||||
@@ -103,10 +92,6 @@ pub enum CoreCommand {
|
||||
RemoveFriend(EndpointId),
|
||||
/// Locally rename a friend (W7).
|
||||
RenameFriend(EndpointId, String),
|
||||
/// Run an immediate presence-refresh pass over all friends (the manual
|
||||
/// "Rescan" button). Same work the 60s scheduler does on each tick, on demand —
|
||||
/// no waiting for the next interval. A no-op while Invisible.
|
||||
RefreshFriends,
|
||||
/// Set our presence posture (W7). Gates the idle listener (answer friends-only /
|
||||
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
|
||||
/// startup from config and whenever the user changes it.
|
||||
@@ -124,97 +109,6 @@ pub enum CoreCommand {
|
||||
SetGameProcessMap(std::collections::BTreeMap<String, String>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeliveryClass {
|
||||
Reliable,
|
||||
BestEffort,
|
||||
}
|
||||
|
||||
/// Route a command by how bad it is to drop it. Discrete, human-paced user
|
||||
/// actions are Reliable (must land). The only high-frequency commands are the
|
||||
/// continuous audio sliders, where dropping intermediate values is harmless;
|
||||
/// those are BestEffort.
|
||||
pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
||||
match cmd {
|
||||
CoreCommand::SetPeerVolume(_, _)
|
||||
| CoreCommand::SetPeerPan(_, _)
|
||||
| CoreCommand::SetPeerGate(_, _)
|
||||
| CoreCommand::SetPeerEq(_, _)
|
||||
| CoreCommand::SetInputVolume(_)
|
||||
| CoreCommand::SetOutputVolume(_)
|
||||
| CoreCommand::SetNoiseGateThreshold(_) => DeliveryClass::BestEffort,
|
||||
|
||||
CoreCommand::Join {
|
||||
name: _,
|
||||
ticket: _,
|
||||
room_name: _,
|
||||
input_device: _,
|
||||
output_device: _,
|
||||
echo_cancellation: _,
|
||||
avatar: _,
|
||||
}
|
||||
| CoreCommand::Leave
|
||||
| CoreCommand::Shutdown
|
||||
| CoreCommand::ToggleMute
|
||||
| CoreCommand::SetAvatar(_)
|
||||
| CoreCommand::ToggleDeafen
|
||||
| CoreCommand::SetPttMode(_)
|
||||
| CoreCommand::SetPttActive(_)
|
||||
| CoreCommand::SetPeerMuted(_, _)
|
||||
| CoreCommand::SetMicMonitor {
|
||||
enabled: _,
|
||||
input_device: _,
|
||||
}
|
||||
| CoreCommand::SetNetworkMode(_)
|
||||
| CoreCommand::SetRecording(_)
|
||||
| CoreCommand::SetRecordingMode(_)
|
||||
| CoreCommand::SendChat(_)
|
||||
| CoreCommand::SendChatFile {
|
||||
text: _,
|
||||
attachment: _,
|
||||
data: _,
|
||||
}
|
||||
| CoreCommand::FetchAttachment {
|
||||
from: _,
|
||||
attachment: _,
|
||||
}
|
||||
| CoreCommand::ServeMusicTrack {
|
||||
id: _,
|
||||
data: _,
|
||||
}
|
||||
| CoreCommand::ForgetMusicTrack(_)
|
||||
| CoreCommand::SetMusicPresence(_)
|
||||
| CoreCommand::FetchMusic {
|
||||
from: _,
|
||||
id: _,
|
||||
size: _,
|
||||
}
|
||||
| CoreCommand::PrefetchMusic {
|
||||
from: _,
|
||||
id: _,
|
||||
size: _,
|
||||
}
|
||||
| CoreCommand::SetPixelpassPath(_)
|
||||
| CoreCommand::ListAudioApps
|
||||
| CoreCommand::StartScreenShare { audio_app: _ }
|
||||
| CoreCommand::StopScreenShare
|
||||
| CoreCommand::ViewShare(_)
|
||||
| CoreCommand::RegenerateIdentity
|
||||
| CoreCommand::AddFriend {
|
||||
id: _,
|
||||
name: _,
|
||||
addr: _,
|
||||
}
|
||||
| CoreCommand::RemoveFriend(_)
|
||||
| CoreCommand::RenameFriend(_, _)
|
||||
| CoreCommand::RefreshFriends
|
||||
| CoreCommand::SetPresenceMode(_)
|
||||
| CoreCommand::SetGamePresenceEnabled(_)
|
||||
| CoreCommand::SetGameOverride(_)
|
||||
| CoreCommand::SetGameProcessMap(_) => DeliveryClass::Reliable,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UiEvent {
|
||||
RoomJoined { ticket: String, self_id: String },
|
||||
@@ -253,12 +147,6 @@ pub enum UiEvent {
|
||||
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
||||
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
||||
AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
|
||||
/// A tuned-in source's track bytes arrived; play them in the music sink.
|
||||
MusicReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
||||
/// A tuned-in source's next-track bytes arrived; cache them for a gapless swap.
|
||||
MusicPrefetched { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
||||
/// A music-track fetch failed (source gone, too large, etc.).
|
||||
MusicFetchFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
|
||||
/// The apps currently producing audio, for the screen-share audio picker
|
||||
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
|
||||
/// playing or enumeration isn't available. `app_audio_supported` reports
|
||||
@@ -275,10 +163,6 @@ pub enum UiEvent {
|
||||
/// run viewers currently hear silence. The UI shows a transient warning while
|
||||
/// `false`. Only meaningful while sharing a specific app (not whole-desktop).
|
||||
ShareAudioActive(bool),
|
||||
/// A validly signed peer cannot be admitted because its gossip timestamp is
|
||||
/// outside the replay freshness window. `peer_ahead` describes the peer's
|
||||
/// sender-stamped timestamp relative to this machine's clock.
|
||||
ClockSkewWarning { skew_secs: u64, peer_ahead: bool },
|
||||
/// Our node identity (W7): the current node id string, and whether it is
|
||||
/// PERSISTED to disk. Sent once at startup and again after a regenerate.
|
||||
/// `persisted = false` means the key file couldn't be read/written and we're
|
||||
@@ -296,11 +180,6 @@ pub enum UiEvent {
|
||||
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
||||
/// scheduler; absence of a recent event = treat as offline.
|
||||
FriendPresence { id: EndpointId, presence: FriendPresence },
|
||||
/// A manual "Rescan" pass finished (every friend has been probed and its
|
||||
/// per-friend `FriendPresence` already emitted). Lets the GUI clear the
|
||||
/// transient "Rescanning…" status. Sent only for the on-demand button, not the
|
||||
/// periodic auto-refresh, so the status bar isn't churned every interval.
|
||||
FriendsRescanned,
|
||||
/// Core corrected the committed presence posture. Usually the Discoverable
|
||||
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
||||
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
||||
@@ -316,60 +195,3 @@ pub enum UiEvent {
|
||||
ShutdownComplete,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{delivery_class, CoreCommand, DeliveryClass};
|
||||
use crate::audio::eq::EqSettings;
|
||||
use crate::presence::PresenceMode;
|
||||
use iroh::{EndpointId, SecretKey};
|
||||
|
||||
fn endpoint_id() -> EndpointId {
|
||||
SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_audio_controls_are_best_effort() {
|
||||
let peer = endpoint_id();
|
||||
let commands = [
|
||||
CoreCommand::SetPeerVolume(peer, 0.7),
|
||||
CoreCommand::SetPeerPan(peer, -0.2),
|
||||
CoreCommand::SetPeerGate(peer, 0.1),
|
||||
CoreCommand::SetPeerEq(peer, EqSettings::default()),
|
||||
CoreCommand::SetInputVolume(0.8),
|
||||
CoreCommand::SetOutputVolume(0.9),
|
||||
CoreCommand::SetNoiseGateThreshold(0.02),
|
||||
];
|
||||
|
||||
for cmd in commands {
|
||||
assert_eq!(delivery_class(&cmd), DeliveryClass::BestEffort);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discrete_user_actions_are_reliable() {
|
||||
let peer = endpoint_id();
|
||||
let commands = [
|
||||
CoreCommand::ToggleMute,
|
||||
CoreCommand::SetPttActive(false),
|
||||
CoreCommand::Leave,
|
||||
CoreCommand::RegenerateIdentity,
|
||||
CoreCommand::Join {
|
||||
name: "Peer".to_string(),
|
||||
ticket: "create".to_string(),
|
||||
room_name: "Room".to_string(),
|
||||
input_device: None,
|
||||
output_device: None,
|
||||
echo_cancellation: true,
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
},
|
||||
CoreCommand::SetPeerMuted(peer, true),
|
||||
CoreCommand::SetPresenceMode(PresenceMode::Normal),
|
||||
CoreCommand::SendChat("hello".to_string()),
|
||||
];
|
||||
|
||||
for cmd in commands {
|
||||
assert_eq!(delivery_class(&cmd), DeliveryClass::Reliable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+48
-221
@@ -11,7 +11,7 @@ use crate::network::{
|
||||
iroh_impl::{IrohTransport, AudioRouter, FileRouter},
|
||||
gossip::IrohGossipState,
|
||||
};
|
||||
use crate::core::messages::{CoreCommand, DeliveryClass, UiEvent, delivery_class};
|
||||
use crate::core::messages::{CoreCommand, UiEvent};
|
||||
use crate::core::recovery::RecoveryCoordinator;
|
||||
|
||||
use crate::config::{NetworkMode, RecordingMode};
|
||||
@@ -26,44 +26,38 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct CoreController {
|
||||
reliable_tx: mpsc::UnboundedSender<CoreCommand>,
|
||||
besteffort_tx: mpsc::Sender<CoreCommand>,
|
||||
cmd_tx: mpsc::Sender<CoreCommand>,
|
||||
}
|
||||
|
||||
impl CoreController {
|
||||
pub fn new(ui_tx: mpsc::Sender<UiEvent>) -> Self {
|
||||
let (reliable_tx, reliable_rx) = mpsc::unbounded_channel();
|
||||
let (besteffort_tx, besteffort_rx) = mpsc::channel(100);
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel(100);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");
|
||||
rt.block_on(async move {
|
||||
crate::log_msg("Starting core network loop in dedicated Tokio runtime");
|
||||
if let Err(e) = run_core_loop(reliable_rx, besteffort_rx, ui_tx).await {
|
||||
if let Err(e) = run_core_loop(cmd_rx, ui_tx).await {
|
||||
crate::log_msg(&format!("App core loop failed: {:?}", e));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Self { reliable_tx, besteffort_tx }
|
||||
Self { cmd_tx }
|
||||
}
|
||||
|
||||
/// Queue a command for the core loop. Reliable commands only fail when the
|
||||
/// core loop is dead; best-effort slider commands keep today's bounded
|
||||
/// try-send behavior. (We return a plain bool rather than the channel's
|
||||
/// `Result` so the bulky `CoreCommand` isn't carried back by value in every
|
||||
/// caller's error type.)
|
||||
/// Queue a command for the core loop, best-effort. Returns `true` if it was
|
||||
/// accepted, `false` if the channel is full or closed. (We return a plain
|
||||
/// bool rather than the channel's `Result` so the bulky `CoreCommand` isn't
|
||||
/// carried back by value in every caller's error type.)
|
||||
pub fn send(&self, cmd: CoreCommand) -> bool {
|
||||
match delivery_class(&cmd) {
|
||||
DeliveryClass::Reliable => self.reliable_tx.send(cmd).is_ok(),
|
||||
DeliveryClass::BestEffort => self.besteffort_tx.try_send(cmd).is_ok(),
|
||||
}
|
||||
self.cmd_tx.try_send(cmd).is_ok()
|
||||
}
|
||||
|
||||
/// Clone the command sender for asynchronous one-shot sends that should wait
|
||||
/// for channel capacity instead of failing immediately on a full queue.
|
||||
pub fn command_sender(&self) -> mpsc::Sender<CoreCommand> {
|
||||
self.besteffort_tx.clone()
|
||||
self.cmd_tx.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,17 +294,6 @@ fn apply_volume(frame: &mut [i16], vol: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the listener's per-peer volume for the audio sender id currently being
|
||||
/// mixed. The map key must be the same `EndpointId` used for the jitter buffer.
|
||||
fn apply_peer_volume(
|
||||
frame: &mut [i16],
|
||||
peer_id: EndpointId,
|
||||
volumes: &HashMap<EndpointId, f32>,
|
||||
) {
|
||||
let vol = volumes.get(&peer_id).copied().unwrap_or(1.0);
|
||||
apply_volume(frame, vol);
|
||||
}
|
||||
|
||||
/// Normalized RMS level of a frame in `[0.0, 1.0]` (32768 = full scale), for the
|
||||
/// UI level meter. An empty frame reads as 0.0.
|
||||
fn frame_level(frame: &[i16]) -> f32 {
|
||||
@@ -865,52 +848,6 @@ fn spawn_attachment_fetch(
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_music_fetch(
|
||||
transport: Arc<IrohTransport>,
|
||||
ui_tx: mpsc::Sender<UiEvent>,
|
||||
from: EndpointId,
|
||||
id: crate::files::AttachmentId,
|
||||
size: u64,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
match transport.fetch_blob(from, id, size).await {
|
||||
Ok(data) => {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::MusicReady { from, id, data })
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::MusicFetchFailed { from, id, error: e.to_string() })
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_music_prefetch(
|
||||
transport: Arc<IrohTransport>,
|
||||
ui_tx: mpsc::Sender<UiEvent>,
|
||||
from: EndpointId,
|
||||
id: crate::files::AttachmentId,
|
||||
size: u64,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
match transport.fetch_blob(from, id, size).await {
|
||||
Ok(data) => {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::MusicPrefetched { from, id, data })
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::MusicFetchFailed { from, id, error: e.to_string() })
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Finalize and clear the active recording, if any, emitting `RecordingStopped`.
|
||||
/// No-op when not recording. Called on stop, room leave, and room switch so a
|
||||
/// recording is always closed cleanly (its WAV size fields patched).
|
||||
@@ -966,24 +903,19 @@ async fn persist_and_emit_friends(
|
||||
.await;
|
||||
}
|
||||
|
||||
/// How often the outbound presence scheduler refreshes friends' status. Each pass
|
||||
/// opens one short connection per friend with a saved address, so the cost scales
|
||||
/// with friend-count, not a fixed per-tick cost. 15s keeps the list feeling live
|
||||
/// without aggressively probing peers for a best-effort signal; the manual Rescan
|
||||
/// button covers the "update now" case below this interval.
|
||||
const PING_INTERVAL: Duration = Duration::from_secs(15);
|
||||
/// How often the outbound presence scheduler refreshes friends' status. Slow on
|
||||
/// purpose — presence is best-effort, not real-time, and each pass opens a short
|
||||
/// connection per friend.
|
||||
const PING_INTERVAL: Duration = Duration::from_secs(60);
|
||||
/// Delay before the FIRST presence pass, so the endpoint's background `online()`
|
||||
/// has a moment to finish (otherwise the first probes fail and friends flash offline).
|
||||
const PING_STARTUP_DELAY: Duration = Duration::from_secs(3);
|
||||
|
||||
/// One outbound presence-refresh pass (W7 B2): probe every friend and emit a
|
||||
/// *definitive* status for each, so the UI self-heals every pass instead of only
|
||||
/// ratcheting a friend upward. A friend with a saved address is probed and mapped
|
||||
/// via [`crate::presence::presence_from_probe`] (a failed probe -> `Offline`); a
|
||||
/// friend with no saved address (a bare add-by-id we've never met in a room) is
|
||||
/// reported `Offline` directly, since a bare id can't resolve without discovery.
|
||||
/// Probes run in parallel (friend counts are small). This is the fix for stale
|
||||
/// "online"/"in a room" statuses lingering after a friend drops or leaves a room.
|
||||
/// One outbound presence-refresh pass (W7 B2): probe every friend that has a saved
|
||||
/// address and emit their interpreted status. Friends with no saved address are
|
||||
/// skipped (a bare id can't resolve without discovery) and stay offline in the UI
|
||||
/// until first contact populates their address via `note_seen`. Probes run in
|
||||
/// parallel (friend counts are small); an unreachable friend just yields nothing.
|
||||
async fn probe_friends_once(
|
||||
endpoint: Endpoint,
|
||||
friends: crate::friends::FriendStore,
|
||||
@@ -991,33 +923,25 @@ async fn probe_friends_once(
|
||||
) {
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for f in friends.list() {
|
||||
let Some(addr) = f.last_addr.clone() else { continue };
|
||||
let id = f.id;
|
||||
let Some(addr) = f.last_addr.clone() else {
|
||||
// Nothing to dial yet — report Offline so a prior status can't stick.
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::FriendPresence { id, presence: crate::presence::FriendPresence::Offline })
|
||||
.await;
|
||||
continue;
|
||||
};
|
||||
let ep = endpoint.clone();
|
||||
set.spawn(async move {
|
||||
let presence = match crate::presence_net::probe(&ep, addr).await {
|
||||
Ok((from, reply)) => crate::presence::presence_from_probe(Some((&reply, from))),
|
||||
Err(_) => crate::presence::presence_from_probe(None),
|
||||
};
|
||||
(id, presence)
|
||||
match crate::presence_net::probe(&ep, addr).await {
|
||||
Ok((from, reply)) => crate::presence::interpret_pong(&reply, from).map(|p| (id, p)),
|
||||
Err(_) => None,
|
||||
}
|
||||
});
|
||||
}
|
||||
while let Some(res) = set.join_next().await {
|
||||
if let Ok((id, presence)) = res {
|
||||
if let Ok(Some((id, presence))) = res {
|
||||
let _ = ui_tx.send(UiEvent::FriendPresence { id, presence }).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_core_loop(
|
||||
mut reliable_rx: mpsc::UnboundedReceiver<CoreCommand>,
|
||||
mut besteffort_rx: mpsc::Receiver<CoreCommand>,
|
||||
mut cmd_rx: mpsc::Receiver<CoreCommand>,
|
||||
ui_tx: mpsc::Sender<UiEvent>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let memory_lookup = iroh::address_lookup::memory::MemoryLookup::new();
|
||||
@@ -1094,7 +1018,6 @@ async fn run_core_loop(
|
||||
name: "Anonymous".to_string(),
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
game: None,
|
||||
music: None,
|
||||
};
|
||||
// Game detection (W17/W18): a background worker polls Steam state + the process
|
||||
// list and publishes the debounced running game on a watch channel. Detection
|
||||
@@ -1151,22 +1074,34 @@ async fn run_core_loop(
|
||||
// Join, cleared on Leave.
|
||||
let current_room: Arc<std::sync::Mutex<Option<crate::presence::RoomPresence>>> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
let presence_rate_limiter =
|
||||
Arc::new(std::sync::Mutex::new(crate::presence::PresenceRateLimiter::default()));
|
||||
|
||||
// Reply policy for the idle friends listener (B2): answer friends only, never
|
||||
// while invisible (`should_answer`), and report our current gathering so a friend
|
||||
// can one-click join. Reads the shared snapshots, so it stays correct as they
|
||||
// change and survives a network-stack rebuild. Pure-sync (no awaits, no lock held
|
||||
// across one). Built once and handed to every `build_net_stack`.
|
||||
// can one-click join. Rate-limits allowed friends before building a reply, so a
|
||||
// spammy saved peer gets the same silent close as an unauthorized peer. Reads the
|
||||
// shared snapshots, so it stays correct as they change and survives a network-stack
|
||||
// rebuild. Pure-sync (no awaits, no lock held across one). Built once and handed
|
||||
// to every `build_net_stack`.
|
||||
let friends_handler: crate::presence_net::Handler = {
|
||||
let friends = friends.clone();
|
||||
let presence_mode = presence_mode.clone();
|
||||
let current_room = current_room.clone();
|
||||
let presence_rate_limiter = presence_rate_limiter.clone();
|
||||
Arc::new(move |from| {
|
||||
let mode = *presence_mode.lock().unwrap();
|
||||
let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode);
|
||||
if !allowed {
|
||||
return None;
|
||||
}
|
||||
if !presence_rate_limiter
|
||||
.lock()
|
||||
.unwrap()
|
||||
.allow(from, std::time::Instant::now())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let room = current_room.lock().unwrap().clone();
|
||||
Some(crate::presence::ControlMsg::Pong { room })
|
||||
})
|
||||
@@ -1221,12 +1156,7 @@ async fn run_core_loop(
|
||||
ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
let cmd = tokio::select! {
|
||||
biased;
|
||||
maybe_cmd = reliable_rx.recv() => match maybe_cmd {
|
||||
Some(cmd) => cmd,
|
||||
None => break,
|
||||
},
|
||||
maybe_cmd = besteffort_rx.recv() => match maybe_cmd {
|
||||
maybe_cmd = cmd_rx.recv() => match maybe_cmd {
|
||||
Some(cmd) => cmd,
|
||||
None => break,
|
||||
},
|
||||
@@ -1789,7 +1719,8 @@ async fn run_core_loop(
|
||||
peer_noise_gates.remove(&peer_id);
|
||||
}
|
||||
|
||||
apply_peer_volume(&mut frame, peer_id, ¤t_volumes);
|
||||
let vol = current_volumes.get(&peer_id).copied().unwrap_or(1.0);
|
||||
apply_volume(&mut frame, vol);
|
||||
|
||||
let eq_settings = current_eq
|
||||
.get(&peer_id)
|
||||
@@ -2161,19 +2092,6 @@ async fn run_core_loop(
|
||||
attachment,
|
||||
}).await;
|
||||
}
|
||||
RoomEvent::ClockSkewSuspected { author, skew_ms } => {
|
||||
crate::log_msg(&format!(
|
||||
"Clock skew suspected for authenticated gossip author={} skew_ms={skew_ms}",
|
||||
crate::short_id(&author.to_string())
|
||||
));
|
||||
let skew_secs = skew_ms.unsigned_abs().saturating_add(999) / 1000;
|
||||
let _ = ui_tx_events
|
||||
.send(UiEvent::ClockSkewWarning {
|
||||
skew_secs,
|
||||
peer_ahead: skew_ms > 0,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
RoomEvent::PeerConnectionLost(peer_id) => {
|
||||
// Transient drop: do NOT tear down the peer. Its audio
|
||||
// supervisor stays alive and keeps redialing the
|
||||
@@ -2506,25 +2424,6 @@ async fn run_core_loop(
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::RefreshFriends => {
|
||||
// Manual "Rescan": run an immediate probe pass (same as a scheduler
|
||||
// tick), detached so it can't block command handling. Honour
|
||||
// Invisible — stay fully dark and touch no friend's machine. A
|
||||
// `FriendsRescanned` event always follows so the UI's transient
|
||||
// "Rescanning…" status clears even when probing was skipped.
|
||||
let visible =
|
||||
*presence_mode.lock().unwrap() != crate::presence::PresenceMode::Invisible;
|
||||
let endpoint = net.endpoint.clone();
|
||||
let snapshot = friends.lock().unwrap().clone();
|
||||
let tx = ui_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
if visible {
|
||||
probe_friends_once(endpoint, snapshot, tx.clone()).await;
|
||||
}
|
||||
let _ = tx.send(UiEvent::FriendsRescanned).await;
|
||||
});
|
||||
}
|
||||
|
||||
CoreCommand::SetPresenceMode(mode) => {
|
||||
let previous_mode = *presence_mode.lock().unwrap();
|
||||
let now = tokio::time::Instant::now();
|
||||
@@ -2743,54 +2642,6 @@ async fn run_core_loop(
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::ServeMusicTrack { id, data } => {
|
||||
if let Some(session) = &active_session {
|
||||
session.transport.serve_attachment(id, data);
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::ForgetMusicTrack(id) => {
|
||||
if let Some(session) = &active_session {
|
||||
session.transport.forget_attachment(id);
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SetMusicPresence(music) => {
|
||||
presence.music = music;
|
||||
if let Some(session) = &active_session {
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
net.endpoint.addr(),
|
||||
current_sharing.clone(),
|
||||
);
|
||||
let _ = session.room_state.update_self_state(self_state).await;
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::FetchMusic { from, id, size } => {
|
||||
if let Some(session) = &active_session {
|
||||
spawn_music_fetch(
|
||||
session.transport.clone(),
|
||||
ui_tx.clone(),
|
||||
from,
|
||||
id,
|
||||
size,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::PrefetchMusic { from, id, size } => {
|
||||
if let Some(session) = &active_session {
|
||||
spawn_music_prefetch(
|
||||
session.transport.clone(),
|
||||
ui_tx.clone(),
|
||||
from,
|
||||
id,
|
||||
size,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SetPixelpassPath(path) => {
|
||||
pixelpass_override = path.filter(|p| !p.trim().is_empty());
|
||||
}
|
||||
@@ -2937,9 +2788,9 @@ async fn run_core_loop(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
admit_retained, apply_peer_volume, apply_volume, audio_datagram_len_ok, frame_level,
|
||||
mix_frames, mix_stereo_frames, next_game_change, should_auto_fetch, stereo_to_mono,
|
||||
KnownPeers, MicLevelMeter, PeerSpeakTicket, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS,
|
||||
admit_retained, apply_volume, audio_datagram_len_ok, frame_level, mix_frames,
|
||||
mix_stereo_frames, next_game_change, should_auto_fetch, stereo_to_mono, KnownPeers,
|
||||
MicLevelMeter, PeerSpeakTicket, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS,
|
||||
MIC_LEVEL_REPORT_SAMPLES,
|
||||
};
|
||||
|
||||
@@ -3199,30 +3050,6 @@ mod tests {
|
||||
assert_eq!(frame, vec![2000, -2000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_volume_map_scales_the_matching_audio_peer_frame() {
|
||||
let peer = iroh::SecretKey::generate().public();
|
||||
let other_peer = iroh::SecretKey::generate().public();
|
||||
let volumes = std::collections::HashMap::from([(peer, 0.5), (other_peer, 2.0)]);
|
||||
let mut frame = vec![100, -200, 300, -400];
|
||||
|
||||
apply_peer_volume(&mut frame, peer, &volumes);
|
||||
|
||||
assert_eq!(frame, vec![50, -100, 150, -200]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_volume_map_defaults_to_unity_when_audio_peer_key_is_unmatched() {
|
||||
let ui_peer = iroh::SecretKey::generate().public();
|
||||
let audio_peer = iroh::SecretKey::generate().public();
|
||||
let volumes = std::collections::HashMap::from([(ui_peer, 0.5)]);
|
||||
let mut frame = vec![100, -200, 300, -400];
|
||||
|
||||
apply_peer_volume(&mut frame, audio_peer, &volumes);
|
||||
|
||||
assert_eq!(frame, vec![100, -200, 300, -400]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_peers_sum_without_saturation() {
|
||||
let a = vec![10, 20];
|
||||
|
||||
@@ -20,9 +20,7 @@ pub mod recents;
|
||||
pub mod discovery;
|
||||
pub mod hotkeys;
|
||||
pub mod files;
|
||||
pub mod playlist;
|
||||
pub mod game;
|
||||
pub mod widget;
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
+3
-228
@@ -152,100 +152,6 @@ fn prune_stale_mutations(
|
||||
seen.retain(|_, last_ts| *last_ts >= floor);
|
||||
}
|
||||
|
||||
/// Three signed, out-of-window payloads inside one minute is enough to distinguish
|
||||
/// a persistently skewed clock from a single delayed gossip frame without making
|
||||
/// the user wait long. Repeats are suppressed for five minutes per author.
|
||||
const CLOCK_SKEW_OBSERVATION_WINDOW_MS: u64 = 60_000;
|
||||
const CLOCK_SKEW_WARNING_THRESHOLD: usize = 3;
|
||||
const CLOCK_SKEW_COOLDOWN_MS: u64 = 5 * 60_000;
|
||||
const CLOCK_SKEW_AUTHORS_SOFT_CAP: usize = 256;
|
||||
const CLOCK_SKEW_AUTHORS_HARD_CAP: usize = 512;
|
||||
const CLOCK_SKEW_AUTHOR_TTL_MS: u64 = CLOCK_SKEW_COOLDOWN_MS;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct ClockSkewWarning {
|
||||
author: EndpointId,
|
||||
/// Positive means the peer's sender-stamped clock is ahead of ours.
|
||||
skew_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ClockSkewMonitor {
|
||||
authors: HashMap<EndpointId, ClockSkewAuthorState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ClockSkewAuthorState {
|
||||
observed_at: Vec<u64>,
|
||||
last_seen_ms: u64,
|
||||
last_warned_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl ClockSkewMonitor {
|
||||
fn observe(
|
||||
&mut self,
|
||||
author: EndpointId,
|
||||
skew_ms: i64,
|
||||
now_ms: u64,
|
||||
) -> Option<ClockSkewWarning> {
|
||||
if self.authors.len() > CLOCK_SKEW_AUTHORS_SOFT_CAP {
|
||||
self.prune_stale_authors(now_ms);
|
||||
}
|
||||
|
||||
let warning = {
|
||||
let state = self.authors.entry(author).or_default();
|
||||
state.last_seen_ms = now_ms;
|
||||
let floor = now_ms.saturating_sub(CLOCK_SKEW_OBSERVATION_WINDOW_MS);
|
||||
state.observed_at.retain(|ts| *ts >= floor);
|
||||
state.observed_at.push(now_ms);
|
||||
if state.observed_at.len() > CLOCK_SKEW_WARNING_THRESHOLD {
|
||||
let excess = state.observed_at.len() - CLOCK_SKEW_WARNING_THRESHOLD;
|
||||
state.observed_at.drain(0..excess);
|
||||
}
|
||||
|
||||
let threshold_met = state.observed_at.len() >= CLOCK_SKEW_WARNING_THRESHOLD;
|
||||
let in_cooldown = state
|
||||
.last_warned_ms
|
||||
.is_some_and(|last| now_ms.saturating_sub(last) < CLOCK_SKEW_COOLDOWN_MS);
|
||||
if threshold_met && !in_cooldown {
|
||||
state.last_warned_ms = Some(now_ms);
|
||||
Some(ClockSkewWarning { author, skew_ms })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if self.authors.len() > CLOCK_SKEW_AUTHORS_HARD_CAP {
|
||||
self.drop_oldest_authors();
|
||||
}
|
||||
|
||||
warning
|
||||
}
|
||||
|
||||
fn prune_stale_authors(&mut self, now_ms: u64) {
|
||||
let stale_before = now_ms.saturating_sub(CLOCK_SKEW_AUTHOR_TTL_MS);
|
||||
self.authors.retain(|_, state| {
|
||||
let last_warning_live = state
|
||||
.last_warned_ms
|
||||
.is_some_and(|last| now_ms.saturating_sub(last) < CLOCK_SKEW_COOLDOWN_MS);
|
||||
last_warning_live || state.last_seen_ms >= stale_before
|
||||
});
|
||||
}
|
||||
|
||||
fn drop_oldest_authors(&mut self) {
|
||||
let remove_count = self.authors.len().saturating_sub(CLOCK_SKEW_AUTHORS_SOFT_CAP);
|
||||
let mut by_age: Vec<_> = self
|
||||
.authors
|
||||
.iter()
|
||||
.map(|(author, state)| (*author, state.last_seen_ms))
|
||||
.collect();
|
||||
by_age.sort_by_key(|(_, last_seen_ms)| *last_seen_ms);
|
||||
for (author, _) in by_age.into_iter().take(remove_count) {
|
||||
self.authors.remove(&author);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum number of distinct peers we hold in a room roster at once.
|
||||
///
|
||||
/// Everyone with the room ticket is an authenticated *insider*: a signature only
|
||||
@@ -507,7 +413,6 @@ impl RoomState for IrohGossipState {
|
||||
let handle = tokio::spawn(async move {
|
||||
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
|
||||
let mut state_mutations_seen = HashMap::new();
|
||||
let mut clock_skew_monitor = ClockSkewMonitor::default();
|
||||
|
||||
// Broadcast initial state
|
||||
let initial_payload = {
|
||||
@@ -548,28 +453,9 @@ impl RoomState for IrohGossipState {
|
||||
// action: a forged/stale payload is dropped here
|
||||
// so it can't impersonate, evict, or poison
|
||||
// presence/address-book (security S2).
|
||||
let received_now_ms = now_millis();
|
||||
if let Err(reason) = verify_gossip(
|
||||
&payload,
|
||||
&topic_bytes,
|
||||
received_now_ms,
|
||||
GOSSIP_FRESHNESS_MS,
|
||||
) {
|
||||
if reason == GossipReject::OutOfWindow {
|
||||
let skew_ms = payload.ts as i64 - received_now_ms as i64;
|
||||
if let Some(warning) = clock_skew_monitor.observe(
|
||||
payload.author,
|
||||
skew_ms,
|
||||
received_now_ms,
|
||||
) {
|
||||
let _ = event_tx
|
||||
.send(RoomEvent::ClockSkewSuspected {
|
||||
author: warning.author,
|
||||
skew_ms: warning.skew_ms,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Err(reason) =
|
||||
verify_gossip(&payload, &topic_bytes, now_millis(), GOSSIP_FRESHNESS_MS)
|
||||
{
|
||||
crate::log_msg(&format!(
|
||||
"Gossip dropped unauthenticated/stale payload claiming author={:?}: {:?}",
|
||||
payload.author, reason
|
||||
@@ -645,29 +531,6 @@ impl RoomState for IrohGossipState {
|
||||
let cleaned = crate::sanitize::sanitize_game_label(&g);
|
||||
(!cleaned.is_empty()).then_some(cleaned)
|
||||
});
|
||||
// Music presence is untrusted peer data:
|
||||
// the track name is display text (sanitize
|
||||
// + cap like the game label) and the size
|
||||
// bounds a future fetch (reject anything
|
||||
// outside the attachment cap).
|
||||
state.music = state.music.and_then(|mut m| {
|
||||
let name = crate::sanitize::sanitize_game_label(&m.name);
|
||||
if name.is_empty() || !crate::files::size_within_cap(m.size) {
|
||||
return None;
|
||||
}
|
||||
m.name = name;
|
||||
if m.next_id.is_some() {
|
||||
let ok = m
|
||||
.next_size
|
||||
.map(crate::files::size_within_cap)
|
||||
.unwrap_or(false);
|
||||
if !ok {
|
||||
m.next_id = None;
|
||||
m.next_size = None;
|
||||
}
|
||||
}
|
||||
Some(m)
|
||||
});
|
||||
// Bound an insider's advertised address set
|
||||
// before we retain it / hand it to the dialer
|
||||
// (Tier C F-01).
|
||||
@@ -982,7 +845,6 @@ mod tests {
|
||||
sharing: None,
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
game: None,
|
||||
music: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1088,93 +950,6 @@ mod tests {
|
||||
assert!(!seen.contains_key(&(a, StateMutationKind::Announce)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_monitor_single_drop_does_not_warn() {
|
||||
let author = fresh_id();
|
||||
let mut monitor = ClockSkewMonitor::default();
|
||||
|
||||
assert_eq!(monitor.observe(author, -121_000, 10_000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_monitor_three_drops_in_window_warn_once() {
|
||||
let author = fresh_id();
|
||||
let mut monitor = ClockSkewMonitor::default();
|
||||
|
||||
assert_eq!(monitor.observe(author, -121_000, 10_000), None);
|
||||
assert_eq!(monitor.observe(author, -122_000, 40_000), None);
|
||||
assert_eq!(
|
||||
monitor.observe(author, -123_000, 69_999),
|
||||
Some(ClockSkewWarning { author, skew_ms: -123_000 })
|
||||
);
|
||||
assert_eq!(monitor.observe(author, -124_000, 70_000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_monitor_cooldown_suppresses_repeats() {
|
||||
let author = fresh_id();
|
||||
let mut monitor = ClockSkewMonitor::default();
|
||||
|
||||
assert_eq!(monitor.observe(author, 121_000, 0), None);
|
||||
assert_eq!(monitor.observe(author, 122_000, 10_000), None);
|
||||
assert!(monitor.observe(author, 123_000, 20_000).is_some());
|
||||
|
||||
assert_eq!(monitor.observe(author, 124_000, 30_000), None);
|
||||
assert_eq!(monitor.observe(author, 125_000, 310_000), None);
|
||||
assert_eq!(monitor.observe(author, 126_000, 319_000), None);
|
||||
assert_eq!(monitor.observe(author, 127_000, 319_999), None);
|
||||
assert_eq!(
|
||||
monitor.observe(author, 128_000, 320_000),
|
||||
Some(ClockSkewWarning { author, skew_ms: 128_000 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_monitor_tracks_distinct_authors_independently() {
|
||||
let a = fresh_id();
|
||||
let b = fresh_id();
|
||||
let mut monitor = ClockSkewMonitor::default();
|
||||
|
||||
assert_eq!(monitor.observe(a, -121_000, 0), None);
|
||||
assert_eq!(monitor.observe(a, -121_000, 1_000), None);
|
||||
assert_eq!(monitor.observe(b, 121_000, 0), None);
|
||||
assert_eq!(monitor.observe(b, 121_000, 1_000), None);
|
||||
assert_eq!(
|
||||
monitor.observe(b, 121_000, 2_000),
|
||||
Some(ClockSkewWarning { author: b, skew_ms: 121_000 })
|
||||
);
|
||||
assert_eq!(
|
||||
monitor.observe(a, -121_000, 2_000),
|
||||
Some(ClockSkewWarning { author: a, skew_ms: -121_000 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_monitor_prunes_stale_authors_when_over_cap() {
|
||||
let mut monitor = ClockSkewMonitor::default();
|
||||
for _ in 0..=CLOCK_SKEW_AUTHORS_SOFT_CAP {
|
||||
assert_eq!(monitor.observe(fresh_id(), -121_000, 1), None);
|
||||
}
|
||||
assert!(monitor.authors.len() > CLOCK_SKEW_AUTHORS_SOFT_CAP);
|
||||
|
||||
let current = fresh_id();
|
||||
assert_eq!(
|
||||
monitor.observe(current, -121_000, CLOCK_SKEW_AUTHOR_TTL_MS + 2),
|
||||
None
|
||||
);
|
||||
assert_eq!(monitor.authors.len(), 1);
|
||||
assert!(monitor.authors.contains_key(¤t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_monitor_hard_cap_bounds_fresh_author_growth() {
|
||||
let mut monitor = ClockSkewMonitor::default();
|
||||
for now_ms in 0..(CLOCK_SKEW_AUTHORS_HARD_CAP as u64 + 10) {
|
||||
let _ = monitor.observe(fresh_id(), -121_000, now_ms);
|
||||
assert!(monitor.authors.len() <= CLOCK_SKEW_AUTHORS_HARD_CAP);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_endpoint_addr_caps_address_count() {
|
||||
use std::net::SocketAddr;
|
||||
|
||||
+10
-23
@@ -594,21 +594,17 @@ impl IrohTransport {
|
||||
self.shared.served_files.lock().unwrap().insert(id, bytes);
|
||||
}
|
||||
|
||||
/// Drop a previously-served blob (e.g. a music track no longer current-or-next).
|
||||
pub fn forget_attachment(&self, id: AttachmentId) {
|
||||
self.shared.served_files.lock().unwrap().remove(&id);
|
||||
}
|
||||
|
||||
/// Fetch `size` bytes stored under `id` from peer `from` over the files plane.
|
||||
/// Shared core of `fetch_attachment` and music-track fetching: dials
|
||||
/// `FILES_ALPN`, writes the 32-byte id, and reads bounded by `size`.
|
||||
pub async fn fetch_blob(
|
||||
/// Fetch a chat attachment's bytes from its sender over the file plane. Dials
|
||||
/// the sender on `FILES_ALPN` (preferring a known full address), writes the
|
||||
/// 32-byte id, and reads the response bounded by the descriptor's declared
|
||||
/// size (which the caller has already validated against the global cap). The
|
||||
/// read limit means a malicious sender can't stream us more than advertised.
|
||||
pub async fn fetch_attachment(
|
||||
&self,
|
||||
from: EndpointId,
|
||||
id: AttachmentId,
|
||||
size: u64,
|
||||
att: &ChatAttachment,
|
||||
) -> Result<Vec<u8>, NetError> {
|
||||
if !crate::files::size_within_cap(size) {
|
||||
if !crate::files::size_within_cap(att.size) {
|
||||
return Err(NetError::Other("attachment size out of range".to_string()));
|
||||
}
|
||||
let addr = self.shared.addrs.lock().unwrap().get(&from).cloned();
|
||||
@@ -627,13 +623,13 @@ impl IrohTransport {
|
||||
.open_bi()
|
||||
.await
|
||||
.map_err(|e| NetError::Other(format!("file fetch: open stream failed: {e}")))?;
|
||||
send.write_all(&id)
|
||||
send.write_all(&att.id)
|
||||
.await
|
||||
.map_err(|e| NetError::Other(format!("file fetch: request write failed: {e}")))?;
|
||||
send.finish()
|
||||
.map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?;
|
||||
|
||||
let read = recv.read_to_end(size as usize);
|
||||
let read = recv.read_to_end(att.size as usize);
|
||||
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
|
||||
.await
|
||||
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
|
||||
@@ -643,15 +639,6 @@ impl IrohTransport {
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Fetch a chat attachment's bytes from its sender over the file plane.
|
||||
pub async fn fetch_attachment(
|
||||
&self,
|
||||
from: EndpointId,
|
||||
att: &ChatAttachment,
|
||||
) -> Result<Vec<u8>, NetError> {
|
||||
self.fetch_blob(from, att.id, att.size).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -22,38 +22,6 @@ pub enum NetError {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// A peer's currently-broadcast music track + playback timeline (W22). Rides
|
||||
/// gossip presence so listeners can tune in, follow track changes, and keep in
|
||||
/// sync. Untrusted like `name`/`game`: the `name` is sanitized and `size` is
|
||||
/// cap-checked at gossip ingest. Bytes never ride gossip — they are fetched
|
||||
/// point-to-point over the files plane by `id`, exactly like a chat attachment.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MusicPresence {
|
||||
/// Files-plane handle to fetch this track's bytes (minted per track by the DJ).
|
||||
pub id: crate::files::AttachmentId,
|
||||
/// Sanitized display name (track filename). Untrusted; cleaned at ingest.
|
||||
pub name: String,
|
||||
/// Byte length, bounds the listener's fetch. Must be `<= MAX_ATTACHMENT_BYTES`.
|
||||
pub size: u64,
|
||||
/// True while the DJ has the track paused.
|
||||
pub paused: bool,
|
||||
/// Wall-clock ms (UNIX epoch) of the timeline anchor. While playing, the true
|
||||
/// playhead is `position_ms + (now_ms - anchor_ms)`; while paused it is
|
||||
/// frozen at `position_ms`. Re-stamped on every play/pause/seek.
|
||||
pub anchor_ms: u64,
|
||||
/// Playhead position (ms) at `anchor_ms`.
|
||||
pub position_ms: u64,
|
||||
/// Files-plane handle for the DJ's NEXT track, so listeners can prefetch it
|
||||
/// for a gapless skip. `None` when there is no distinct next track (single
|
||||
/// item playlist) or the DJ isn't ready. Equals a future `id` once that
|
||||
/// track plays.
|
||||
#[serde(default)]
|
||||
pub next_id: Option<crate::files::AttachmentId>,
|
||||
/// Byte length of the next track; bounds the prefetch. Cap-checked at ingest.
|
||||
#[serde(default)]
|
||||
pub next_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PeerState {
|
||||
pub name: String,
|
||||
@@ -80,10 +48,6 @@ pub struct PeerState {
|
||||
/// Defaulted so peers/configs predating the field still deserialize.
|
||||
#[serde(default)]
|
||||
pub game: Option<String>,
|
||||
/// This peer's currently-broadcast music track and playback timeline, or
|
||||
/// `None` when not broadcasting. Defaulted so pre-W22 peers deserialize.
|
||||
#[serde(default)]
|
||||
pub music: Option<MusicPresence>,
|
||||
}
|
||||
|
||||
/// The locally-owned, "sticky" pieces of our own presence: the identity fields
|
||||
@@ -106,8 +70,6 @@ pub struct SelfPresence {
|
||||
/// (see `crate::sanitize::sanitize_game_label`) before being stored here, so
|
||||
/// the outgoing announce carries a safe value.
|
||||
pub game: Option<String>,
|
||||
/// Our current broadcast timeline, or `None` when not broadcasting / not playing.
|
||||
pub music: Option<MusicPresence>,
|
||||
}
|
||||
|
||||
impl SelfPresence {
|
||||
@@ -127,7 +89,6 @@ impl SelfPresence {
|
||||
sharing,
|
||||
avatar: self.avatar.clone(),
|
||||
game: self.game.clone(),
|
||||
music: self.music.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,10 +106,6 @@ pub enum RoomEvent {
|
||||
/// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the
|
||||
/// reconnect path the way it used to.
|
||||
PeerConnectionLost(EndpointId),
|
||||
/// A validly signed gossip payload was rejected only because its timestamp is
|
||||
/// outside the replay-protection window. The peer is not in the roster yet,
|
||||
/// so this surfaces as a room-level warning instead of a peer-card state.
|
||||
ClockSkewSuspected { author: EndpointId, skew_ms: i64 },
|
||||
/// A peer sent a room text-chat message. Carries the sender's id, their
|
||||
/// display name (embedded so it shows even without a presence entry), the
|
||||
/// text, and a sender-stamped millisecond timestamp.
|
||||
@@ -346,7 +303,6 @@ mod tests {
|
||||
sharing: None,
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
game: None,
|
||||
music: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,7 +418,6 @@ mod tests {
|
||||
name: "Alice".to_string(),
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
game: Some("Half-Life 2".to_string()),
|
||||
music: None,
|
||||
};
|
||||
// Volatile fields come from the call; sticky fields from the struct.
|
||||
let muted = presence.to_state(true, addr.clone(), Some("ticket".to_string()));
|
||||
@@ -486,21 +441,4 @@ mod tests {
|
||||
let deserialized: PeerState = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn music_presence_serde_round_trip() {
|
||||
let original = MusicPresence {
|
||||
id: [3u8; 32],
|
||||
name: "track.ogg".to_string(),
|
||||
size: 1234,
|
||||
paused: false,
|
||||
anchor_ms: 1_700_000_000_000,
|
||||
position_ms: 42_000,
|
||||
next_id: None,
|
||||
next_size: None,
|
||||
};
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: MusicPresence = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum PlaylistKind {
|
||||
M3u,
|
||||
Pls,
|
||||
}
|
||||
|
||||
/// Classify a path by extension into a playlist kind, or None if it is not a
|
||||
/// recognized playlist file. Case-insensitive: m3u/m3u8 -> M3u, pls -> Pls.
|
||||
pub fn playlist_kind(path: &Path) -> Option<PlaylistKind> {
|
||||
let ext = path.extension()?.to_string_lossy();
|
||||
match ext.to_ascii_lowercase().as_str() {
|
||||
"m3u" | "m3u8" => Some(PlaylistKind::M3u),
|
||||
"pls" => Some(PlaylistKind::Pls),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an m3u/m3u8 or pls playlist into local audio file paths. Remote entries
|
||||
/// (http/https/ftp URLs) and non-audio entries are skipped; relative paths are
|
||||
/// resolved against `base_dir` (the playlist file's parent directory). Order is
|
||||
/// preserved. Does not touch the filesystem.
|
||||
pub fn parse_playlist(contents: &str, base_dir: &Path, kind: PlaylistKind) -> Vec<PathBuf> {
|
||||
let entries: Vec<&str> = match kind {
|
||||
PlaylistKind::M3u => contents
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.collect(),
|
||||
PlaylistKind::Pls => contents
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (key, value) = line.split_once('=')?;
|
||||
key.trim()
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("file")
|
||||
.then_some(value.trim())
|
||||
})
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect(),
|
||||
};
|
||||
|
||||
entries
|
||||
.into_iter()
|
||||
.filter_map(|entry| playlist_entry_path(entry, base_dir))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn playlist_entry_path(entry: &str, base_dir: &Path) -> Option<PathBuf> {
|
||||
let lower = entry.to_ascii_lowercase();
|
||||
if lower.starts_with("http://")
|
||||
|| lower.starts_with("https://")
|
||||
|| lower.starts_with("ftp://")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = Path::new(entry);
|
||||
let resolved = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
base_dir.join(path)
|
||||
};
|
||||
let file_name = resolved.file_name()?.to_string_lossy();
|
||||
crate::files::looks_like_audio_name(&file_name).then_some(resolved)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn m3u_skips_comments_and_remote_urls() {
|
||||
let base = Path::new("/music/lists");
|
||||
let contents = "\
|
||||
#EXTM3U
|
||||
#EXTINF:123,Artist - Song
|
||||
tracks/song.ogg
|
||||
https://example.com/stream.mp3
|
||||
";
|
||||
assert_eq!(
|
||||
parse_playlist(contents, base, PlaylistKind::M3u),
|
||||
vec![PathBuf::from("/music/lists/tracks/song.ogg")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pls_keeps_file_values_and_skips_non_audio() {
|
||||
let base = Path::new("/music");
|
||||
let contents = "\
|
||||
[playlist]
|
||||
File1=one.flac
|
||||
Title1=One
|
||||
File2=notes.txt
|
||||
File3=/var/audio/two.MP3
|
||||
";
|
||||
assert_eq!(
|
||||
parse_playlist(contents, base, PlaylistKind::Pls),
|
||||
vec![
|
||||
PathBuf::from("/music/one.flac"),
|
||||
PathBuf::from("/var/audio/two.MP3"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playlist_kind_is_case_insensitive() {
|
||||
assert_eq!(playlist_kind(Path::new("mix.M3U")), Some(PlaylistKind::M3u));
|
||||
assert_eq!(playlist_kind(Path::new("mix.m3u8")), Some(PlaylistKind::M3u));
|
||||
assert_eq!(playlist_kind(Path::new("mix.PLS")), Some(PlaylistKind::Pls));
|
||||
assert_eq!(playlist_kind(Path::new("mix.txt")), None);
|
||||
}
|
||||
}
|
||||
+83
-48
@@ -15,6 +15,16 @@
|
||||
use crate::friends::FriendStore;
|
||||
use iroh::EndpointId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Maximum immediate presence replies to one friend before throttling. Normal
|
||||
/// presence polling is once per minute, so this only catches repeated/manual or
|
||||
/// abusive probes while still allowing a short burst after app startup.
|
||||
pub const PRESENCE_RATE_LIMIT_BURST: u32 = 4;
|
||||
|
||||
/// Refill one presence-reply token per friend at this cadence.
|
||||
pub const PRESENCE_RATE_LIMIT_REFILL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// The user's presence posture — how reachable they are to friends while idle.
|
||||
/// Persisted in `AppConfig`; the default keeps you privately reachable to friends
|
||||
@@ -100,11 +110,49 @@ pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMod
|
||||
mode.answers_pings() && friends.contains(from)
|
||||
}
|
||||
|
||||
/// What we learned about a friend's reachability. `Online`/`InRoom` come from a
|
||||
/// successful ping reply (see [`interpret_pong`]); `Offline` is produced by the
|
||||
/// presence scheduler when a probe fails or the friend has no known address, so a
|
||||
/// friend who drops or leaves is *actively* downgraded rather than left showing a
|
||||
/// stale status. The UI also treats a missing entry as offline.
|
||||
#[derive(Debug, Clone)]
|
||||
struct RateBucket {
|
||||
tokens: u32,
|
||||
last_refill: Instant,
|
||||
}
|
||||
|
||||
/// Per-friend limiter for inbound presence pings. It is intentionally keyed by
|
||||
/// the authenticated connection id, not payload data. Callers should only invoke
|
||||
/// it after [`should_answer`] passes, so strangers do not consume memory here.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct PresenceRateLimiter {
|
||||
buckets: HashMap<EndpointId, RateBucket>,
|
||||
}
|
||||
|
||||
impl PresenceRateLimiter {
|
||||
/// Return whether `from` may receive a presence reply at `now`.
|
||||
///
|
||||
/// This is a token bucket: each friend starts with a small burst and regains
|
||||
/// one token every [`PRESENCE_RATE_LIMIT_REFILL`]. A denied probe should be
|
||||
/// answered with no data, matching the listener's "reveal nothing" policy.
|
||||
pub fn allow(&mut self, from: EndpointId, now: Instant) -> bool {
|
||||
let bucket = self.buckets.entry(from).or_insert(RateBucket {
|
||||
tokens: PRESENCE_RATE_LIMIT_BURST,
|
||||
last_refill: now,
|
||||
});
|
||||
|
||||
let elapsed = now.saturating_duration_since(bucket.last_refill);
|
||||
let refill = elapsed.as_secs() / PRESENCE_RATE_LIMIT_REFILL.as_secs();
|
||||
if refill > 0 {
|
||||
let refill = refill.min(u32::MAX as u64) as u32;
|
||||
bucket.tokens = PRESENCE_RATE_LIMIT_BURST.min(bucket.tokens.saturating_add(refill));
|
||||
bucket.last_refill = now;
|
||||
}
|
||||
|
||||
if bucket.tokens == 0 {
|
||||
return false;
|
||||
}
|
||||
bucket.tokens -= 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// What we learned about a friend from a successful ping reply.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FriendPresence {
|
||||
/// Online, but not in a gathering we can join.
|
||||
@@ -112,8 +160,6 @@ pub enum FriendPresence {
|
||||
/// Online and in a joinable gathering (name already sanitized, ticket already
|
||||
/// validated as parseable).
|
||||
InRoom { name: String, ticket: String },
|
||||
/// Unreachable: the probe failed, or we have no address to probe yet.
|
||||
Offline,
|
||||
}
|
||||
|
||||
/// Interpret a peer's reply defensively. `from` must be the connection's
|
||||
@@ -146,20 +192,6 @@ pub fn interpret_pong(msg: &ControlMsg, from: EndpointId) -> Option<FriendPresen
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a single probe outcome to a definitive [`FriendPresence`], used by the
|
||||
/// presence scheduler. `Some((reply, from))` is a received message from the
|
||||
/// authenticated remote `from`; `None` means the probe failed (offline /
|
||||
/// unreachable / refused). Anything that doesn't interpret as a real presence —
|
||||
/// a probe error, or a non-`Pong` reply — becomes [`FriendPresence::Offline`], so
|
||||
/// a friend who drops is actively downgraded instead of keeping a stale status.
|
||||
/// Pure so the scheduler's downgrade behaviour is unit-testable without a network.
|
||||
pub fn presence_from_probe(reply: Option<(&ControlMsg, EndpointId)>) -> FriendPresence {
|
||||
match reply {
|
||||
Some((msg, from)) => interpret_pong(msg, from).unwrap_or(FriendPresence::Offline),
|
||||
None => FriendPresence::Offline,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -197,6 +229,36 @@ mod tests {
|
||||
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_rate_limiter_allows_a_small_burst_then_refills() {
|
||||
let mut limiter = PresenceRateLimiter::default();
|
||||
let friend = id();
|
||||
let now = Instant::now();
|
||||
|
||||
for _ in 0..PRESENCE_RATE_LIMIT_BURST {
|
||||
assert!(limiter.allow(friend, now));
|
||||
}
|
||||
assert!(!limiter.allow(friend, now));
|
||||
assert!(!limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL - Duration::from_millis(1)));
|
||||
|
||||
assert!(limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL));
|
||||
assert!(!limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_rate_limiter_is_per_peer() {
|
||||
let mut limiter = PresenceRateLimiter::default();
|
||||
let a = id();
|
||||
let b = id();
|
||||
let now = Instant::now();
|
||||
|
||||
for _ in 0..PRESENCE_RATE_LIMIT_BURST {
|
||||
assert!(limiter.allow(a, now));
|
||||
}
|
||||
assert!(!limiter.allow(a, now));
|
||||
assert!(limiter.allow(b, now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_mode_flags() {
|
||||
assert!(PresenceMode::Discoverable.publishes_to_discovery());
|
||||
@@ -272,33 +334,6 @@ mod tests {
|
||||
assert_eq!(got, Some(FriendPresence::Online));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_from_probe_maps_outcomes_to_definitive_status() {
|
||||
let friend = id();
|
||||
// A failed probe (no reply) is an explicit downgrade to Offline, so the UI
|
||||
// clears a friend who has dropped instead of keeping a stale status.
|
||||
assert_eq!(presence_from_probe(None), FriendPresence::Offline);
|
||||
// A successful Pong with no room is Online.
|
||||
assert_eq!(
|
||||
presence_from_probe(Some((&ControlMsg::Pong { room: None }, friend))),
|
||||
FriendPresence::Online
|
||||
);
|
||||
// A successful Pong advertising the friend's own room is InRoom.
|
||||
let t = valid_ticket(friend);
|
||||
assert_eq!(
|
||||
presence_from_probe(Some((
|
||||
&ControlMsg::Pong { room: Some(RoomPresence { name: "Den".into(), ticket: t.clone() }) },
|
||||
friend,
|
||||
))),
|
||||
FriendPresence::InRoom { name: "Den".into(), ticket: t }
|
||||
);
|
||||
// A non-reply (a stray Ping) is not a presence -> Offline, never a false Online.
|
||||
assert_eq!(
|
||||
presence_from_probe(Some((&ControlMsg::Ping, friend))),
|
||||
FriendPresence::Offline
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_pong_sanitizes_a_hostile_room_name() {
|
||||
// Control/bidi characters in a peer-supplied name are stripped.
|
||||
|
||||
+2
-9
@@ -32,14 +32,7 @@ pub const FRIENDS_PROTO: u32 = 1;
|
||||
/// strictly required for decoding — but per the versioning discipline a wire-shape
|
||||
/// change is isolated into its own topic + signature domain so v2 and v3 peers
|
||||
/// never share a swarm. Resync everyone, exactly like the W4 avatar bump.
|
||||
///
|
||||
/// v4 (0.6.0): `PeerState` gained an optional `music` presence field carrying a
|
||||
/// current shared-listening track descriptor and playback timeline. Bytes still
|
||||
/// ride the files plane by id; gossip carries only the descriptor/timeline.
|
||||
///
|
||||
/// v5 (0.7.0): `MusicPresence` gained optional prefetch hints for the next
|
||||
/// track so tuned-in listeners can fetch it before the DJ advances.
|
||||
pub const GOSSIP_PROTO: u32 = 5;
|
||||
pub const GOSSIP_PROTO: u32 = 3;
|
||||
/// File-transfer plane version (chat attachment request/stream shape). Bump on
|
||||
/// any change. Mirrored in [`FILES_ALPN`].
|
||||
pub const FILES_PROTO: u32 = 1;
|
||||
@@ -54,7 +47,7 @@ pub const FILES_ALPN: &[u8] = b"peerspeak/files/1";
|
||||
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
|
||||
/// the gossip protocol version into every signed payload — a version mismatch
|
||||
/// fails verification (cryptographic separation between gossip versions).
|
||||
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v5";
|
||||
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v3";
|
||||
|
||||
/// Version-namespace a room topic so peers on different gossip protocol versions
|
||||
/// derive **different subscription topics from the same ticket** and therefore
|
||||
|
||||
@@ -1,943 +0,0 @@
|
||||
use iced::advanced::clipboard::{self, Clipboard};
|
||||
use iced::advanced::layout;
|
||||
use iced::advanced::mouse;
|
||||
use iced::advanced::overlay;
|
||||
use iced::advanced::renderer;
|
||||
use iced::advanced::text;
|
||||
use iced::advanced::widget::tree::{self, Tree};
|
||||
use iced::advanced::widget::{self, Widget};
|
||||
use iced::advanced::{Layout, Shell};
|
||||
use iced::widget::text_input;
|
||||
use iced::{
|
||||
alignment, Background, Border, Color, Element, Event, Length, Padding,
|
||||
Pixels, Point, Rectangle, Shadow, Size, Vector,
|
||||
};
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Edit {
|
||||
pub value: String,
|
||||
pub cursor: usize,
|
||||
}
|
||||
|
||||
pub fn copy_selection(value: &str, start: usize, end: usize) -> Option<String> {
|
||||
let value = text_input::Value::new(value);
|
||||
let (start, end) = normalized_range(&value, start, end);
|
||||
|
||||
(start != end).then(|| value.select(start, end).to_string())
|
||||
}
|
||||
|
||||
pub fn cut_selection(
|
||||
value: &str,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> (Edit, Option<String>) {
|
||||
let mut value = text_input::Value::new(value);
|
||||
let (start, end) = normalized_range(&value, start, end);
|
||||
|
||||
if start == end {
|
||||
return (
|
||||
Edit {
|
||||
value: value.to_string(),
|
||||
cursor: start,
|
||||
},
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
let selected = value.select(start, end).to_string();
|
||||
value.remove_many(start, end);
|
||||
|
||||
(
|
||||
Edit {
|
||||
value: value.to_string(),
|
||||
cursor: start,
|
||||
},
|
||||
Some(selected),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit {
|
||||
let mut value = text_input::Value::new(value);
|
||||
let (start, end) = normalized_range(&value, start, end);
|
||||
let clip = text_input::Value::new(clip);
|
||||
let cursor = start + clip.len();
|
||||
|
||||
if start != end {
|
||||
value.remove_many(start, end);
|
||||
}
|
||||
value.insert_many(start, clip);
|
||||
|
||||
Edit {
|
||||
value: value.to_string(),
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_all_range(value: &str) -> (usize, usize) {
|
||||
let value = text_input::Value::new(value);
|
||||
|
||||
(0, value.len())
|
||||
}
|
||||
|
||||
fn normalized_range(
|
||||
value: &text_input::Value,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> (usize, usize) {
|
||||
let len = value.len();
|
||||
|
||||
(start.min(end).min(len), start.max(end).min(len))
|
||||
}
|
||||
|
||||
type InputStyleFn<'a, Theme> =
|
||||
Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
|
||||
|
||||
pub fn context_input<'a, Message, Theme, Renderer>(
|
||||
placeholder: &str,
|
||||
value: &str,
|
||||
) -> ContextInput<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
Theme: text_input::Catalog + 'a,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
ContextInput::new(placeholder, value)
|
||||
}
|
||||
|
||||
pub fn locked_value<'a, Message, Theme, Renderer>(
|
||||
value: &str,
|
||||
noop: Message,
|
||||
) -> ContextInput<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
Theme: text_input::Catalog + 'a,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
ContextInput::new("", value)
|
||||
.on_input(move |_| noop.clone())
|
||||
.locked(true)
|
||||
}
|
||||
|
||||
pub struct ContextInput<
|
||||
'a,
|
||||
Message,
|
||||
Theme = iced::Theme,
|
||||
Renderer = iced::Renderer,
|
||||
> where
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
input: text_input::TextInput<'a, Message, Theme, Renderer>,
|
||||
value: String,
|
||||
is_secure: bool,
|
||||
locked: bool,
|
||||
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||
style: Option<InputStyleFn<'a, Theme>>,
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer>
|
||||
ContextInput<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
Theme: text_input::Catalog + 'a,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
pub fn new(placeholder: &str, value: &str) -> Self {
|
||||
Self {
|
||||
input: text_input::TextInput::new(placeholder, value),
|
||||
value: value.to_owned(),
|
||||
is_secure: false,
|
||||
locked: false,
|
||||
on_input: None,
|
||||
on_paste: None,
|
||||
style: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
|
||||
self.input = self.input.id(id);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn secure(mut self, is_secure: bool) -> Self {
|
||||
self.is_secure = is_secure;
|
||||
self.input = self.input.secure(is_secure);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn locked(mut self, yes: bool) -> Self {
|
||||
self.locked = yes;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_input(
|
||||
mut self,
|
||||
on_input: impl Fn(String) -> Message + 'a,
|
||||
) -> Self {
|
||||
let on_input: Rc<dyn Fn(String) -> Message + 'a> =
|
||||
Rc::new(on_input);
|
||||
let input_callback = Rc::clone(&on_input);
|
||||
|
||||
self.input =
|
||||
self.input.on_input(move |value| input_callback.as_ref()(value));
|
||||
self.on_input = Some(on_input);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_submit(mut self, message: Message) -> Self {
|
||||
self.input = self.input.on_submit(message);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_submit_maybe(mut self, message: Option<Message>) -> Self {
|
||||
self.input = self.input.on_submit_maybe(message);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_paste(
|
||||
mut self,
|
||||
on_paste: impl Fn(String) -> Message + 'a,
|
||||
) -> Self {
|
||||
let on_paste: Rc<dyn Fn(String) -> Message + 'a> =
|
||||
Rc::new(on_paste);
|
||||
let paste_callback = Rc::clone(&on_paste);
|
||||
|
||||
self.input =
|
||||
self.input.on_paste(move |value| paste_callback.as_ref()(value));
|
||||
self.on_paste = Some(on_paste);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn font(mut self, font: Renderer::Font) -> Self {
|
||||
self.input = self.input.font(font);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: text_input::Icon<Renderer::Font>) -> Self {
|
||||
self.input = self.input.icon(icon);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.input = self.input.width(width);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
|
||||
self.input = self.input.padding(padding);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
|
||||
self.input = self.input.size(size);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn line_height(
|
||||
mut self,
|
||||
line_height: impl Into<text::LineHeight>,
|
||||
) -> Self {
|
||||
self.input = self.input.line_height(line_height);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn align_x(
|
||||
mut self,
|
||||
alignment: impl Into<alignment::Horizontal>,
|
||||
) -> Self {
|
||||
self.input = self.input.align_x(alignment);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn style(
|
||||
mut self,
|
||||
style: impl Fn(&Theme, text_input::Status) -> text_input::Style + 'a,
|
||||
) -> Self
|
||||
where
|
||||
Theme::Class<'a>: From<text_input::StyleFn<'a, Theme>>,
|
||||
{
|
||||
let style: InputStyleFn<'a, Theme> = Rc::new(style);
|
||||
let input_style = Rc::clone(&style);
|
||||
|
||||
self.input = self
|
||||
.input
|
||||
.style(move |theme, status| input_style.as_ref()(theme, status));
|
||||
self.style = Some(style);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
|
||||
self.input = self.input.class(class);
|
||||
self.style = None;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ContextInputState {
|
||||
menu: Option<MenuState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct MenuState {
|
||||
anchor: Point,
|
||||
selection: (usize, usize),
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for ContextInput<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone,
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<ContextInputState>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(ContextInputState::default())
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(&self.input as &dyn Widget<_, _, _>)]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
if tree.children.is_empty() {
|
||||
tree.children
|
||||
.push(Tree::new(&self.input as &dyn Widget<_, _, _>));
|
||||
} else {
|
||||
tree.children[0].diff(&self.input as &dyn Widget<_, _, _>);
|
||||
tree.children.truncate(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Widget::size(&self.input)
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
Widget::size_hint(&self.input)
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
Widget::layout(&mut self.input, &mut tree.children[0], renderer, limits)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
Widget::operate(
|
||||
&mut self.input,
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let right_click_on_input = matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
|
||||
) && cursor.is_over(layout.bounds());
|
||||
|
||||
if right_click_on_input {
|
||||
let value = text_input::Value::new(&self.value);
|
||||
let input_state = tree.children[0]
|
||||
.state
|
||||
.downcast_ref::<text_input::State<Renderer::Paragraph>>();
|
||||
let selection = match input_state.cursor().state(&value) {
|
||||
text_input::cursor::State::Index(index) => {
|
||||
let index = index.min(value.len());
|
||||
(index, index)
|
||||
}
|
||||
text_input::cursor::State::Selection { start, end } => {
|
||||
normalized_range(&value, start, end)
|
||||
}
|
||||
};
|
||||
|
||||
tree.state.downcast_mut::<ContextInputState>().menu =
|
||||
cursor.position().map(|anchor| MenuState {
|
||||
anchor,
|
||||
selection,
|
||||
});
|
||||
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
Widget::update(
|
||||
&mut self.input,
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
Widget::draw(
|
||||
&self.input,
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
Widget::mouse_interaction(
|
||||
&self.input,
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut Tree,
|
||||
_layout: Layout<'a>,
|
||||
_renderer: &Renderer,
|
||||
_viewport: &Rectangle,
|
||||
_translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
let Tree {
|
||||
state, children, ..
|
||||
} = tree;
|
||||
let menu = &mut state.downcast_mut::<ContextInputState>().menu;
|
||||
|
||||
if menu.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let input_state = children[0]
|
||||
.state
|
||||
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
|
||||
|
||||
Some(overlay::Element::new(Box::new(ContextMenuOverlay {
|
||||
menu,
|
||||
input_state,
|
||||
value: &self.value,
|
||||
is_secure: self.is_secure,
|
||||
locked: self.locked,
|
||||
on_input: self.on_input.clone(),
|
||||
on_paste: self.on_paste.clone(),
|
||||
style: self.style.clone(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer>
|
||||
From<ContextInput<'a, Message, Theme, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
Theme: text_input::Catalog + 'a,
|
||||
Renderer: text::Renderer + 'a,
|
||||
{
|
||||
fn from(
|
||||
input: ContextInput<'a, Message, Theme, Renderer>,
|
||||
) -> Element<'a, Message, Theme, Renderer> {
|
||||
Element::new(input)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContextMenuOverlay<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
menu: &'a mut Option<MenuState>,
|
||||
input_state: &'a mut text_input::State<Renderer::Paragraph>,
|
||||
value: &'a str,
|
||||
is_secure: bool,
|
||||
locked: bool,
|
||||
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||
style: Option<InputStyleFn<'a, Theme>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MenuAction {
|
||||
Cut,
|
||||
Copy,
|
||||
Paste,
|
||||
SelectAll,
|
||||
}
|
||||
|
||||
impl MenuAction {
|
||||
const ALL: [Self; 4] = [
|
||||
Self::Cut,
|
||||
Self::Copy,
|
||||
Self::Paste,
|
||||
Self::SelectAll,
|
||||
];
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Cut => "Cut",
|
||||
Self::Copy => "Copy",
|
||||
Self::Paste => "Paste",
|
||||
Self::SelectAll => "Select All",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MENU_WIDTH: f32 = 136.0;
|
||||
const ITEM_HEIGHT: f32 = 28.0;
|
||||
const TEXT_SIZE: f32 = 13.0;
|
||||
const MENU_PADDING_X: f32 = 10.0;
|
||||
|
||||
impl<Message, Theme, Renderer> overlay::Overlay<Message, Theme, Renderer>
|
||||
for ContextMenuOverlay<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
fn layout(&mut self, _renderer: &Renderer, bounds: Size) -> layout::Node {
|
||||
let size = Size::new(MENU_WIDTH, ITEM_HEIGHT * MenuAction::ALL.len() as f32);
|
||||
let Some(menu) = self.menu.as_ref() else {
|
||||
return layout::Node::new(Size::ZERO);
|
||||
};
|
||||
let x = menu.anchor.x.min((bounds.width - size.width).max(0.0));
|
||||
let y = menu.anchor.y.min((bounds.height - size.height).max(0.0));
|
||||
|
||||
layout::Node::new(size).move_to(Point::new(x.max(0.0), y.max(0.0)))
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
) {
|
||||
let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active);
|
||||
let hovered_style =
|
||||
input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
|
||||
let bounds = layout.bounds();
|
||||
let viewport = Rectangle::INFINITE;
|
||||
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds,
|
||||
border: Border {
|
||||
radius: 5.0.into(),
|
||||
width: 1.0,
|
||||
color: active_style.border.color,
|
||||
},
|
||||
shadow: Shadow {
|
||||
color: Color::from_rgba(0.0, 0.0, 0.0, 0.22),
|
||||
offset: Vector::new(0.0, 4.0),
|
||||
blur_radius: 10.0,
|
||||
},
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
active_style.background,
|
||||
);
|
||||
|
||||
for (index, action) in MenuAction::ALL.iter().copied().enumerate() {
|
||||
let item_bounds = item_bounds(bounds, index);
|
||||
let enabled = self.enabled(action);
|
||||
let hovered = enabled && cursor.is_over(item_bounds);
|
||||
|
||||
if hovered {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: item_bounds,
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
..Border::default()
|
||||
},
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
Background::Color(hovered_style.selection),
|
||||
);
|
||||
}
|
||||
|
||||
renderer.fill_text(
|
||||
text::Text {
|
||||
content: action.label().to_owned(),
|
||||
bounds: Size::new(item_bounds.width - MENU_PADDING_X * 2.0, item_bounds.height),
|
||||
size: Pixels(TEXT_SIZE),
|
||||
line_height: text::LineHeight::default(),
|
||||
font: renderer.default_font(),
|
||||
align_x: text::Alignment::Default,
|
||||
align_y: alignment::Vertical::Center,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::default(),
|
||||
},
|
||||
Point::new(item_bounds.x + MENU_PADDING_X, item_bounds.center_y()),
|
||||
if enabled {
|
||||
active_style.value
|
||||
} else {
|
||||
disabled_color(active_style.value)
|
||||
},
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
match event {
|
||||
Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(
|
||||
iced::keyboard::key::Named::Escape,
|
||||
),
|
||||
..
|
||||
}) => {
|
||||
self.close(shell);
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
let Some(position) = cursor.position() else {
|
||||
self.close(shell);
|
||||
return;
|
||||
};
|
||||
let bounds = layout.bounds();
|
||||
|
||||
if !bounds.contains(position) {
|
||||
self.close(shell);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(action) = self.hit_action(bounds, position) {
|
||||
if self.enabled(action) {
|
||||
self.perform(action, clipboard, shell);
|
||||
}
|
||||
self.close(shell);
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonPressed(_)) => {
|
||||
let should_close = cursor
|
||||
.position()
|
||||
.is_none_or(|position| !layout.bounds().contains(position));
|
||||
|
||||
if should_close {
|
||||
self.close(shell);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
let Some(position) = cursor.position() else {
|
||||
return mouse::Interaction::default();
|
||||
};
|
||||
|
||||
if self.hit_action(layout.bounds(), position).is_some() {
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
mouse::Interaction::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> ContextMenuOverlay<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
fn enabled(&self, action: MenuAction) -> bool {
|
||||
let Some(menu) = self.menu.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let has_selection = menu.selection.0 != menu.selection.1;
|
||||
let has_value = !text_input::Value::new(self.value).is_empty();
|
||||
|
||||
menu_action_enabled(
|
||||
action,
|
||||
has_selection,
|
||||
has_value,
|
||||
self.is_secure,
|
||||
self.locked,
|
||||
)
|
||||
}
|
||||
|
||||
fn hit_action(
|
||||
&self,
|
||||
bounds: Rectangle,
|
||||
position: Point,
|
||||
) -> Option<MenuAction> {
|
||||
if !bounds.contains(position) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let index = ((position.y - bounds.y) / ITEM_HEIGHT).floor() as usize;
|
||||
|
||||
MenuAction::ALL.get(index).copied()
|
||||
}
|
||||
|
||||
fn perform(
|
||||
&mut self,
|
||||
action: MenuAction,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
let Some(menu) = self.menu.as_ref().copied() else {
|
||||
return;
|
||||
};
|
||||
let (start, end) = menu.selection;
|
||||
|
||||
match action {
|
||||
MenuAction::Cut => {
|
||||
let (edit, selected) = cut_selection(self.value, start, end);
|
||||
|
||||
if let Some(selected) = selected {
|
||||
clipboard.write(clipboard::Kind::Standard, selected);
|
||||
self.publish_edit(edit, shell);
|
||||
}
|
||||
}
|
||||
MenuAction::Copy => {
|
||||
if let Some(selected) = copy_selection(self.value, start, end) {
|
||||
clipboard.write(clipboard::Kind::Standard, selected);
|
||||
}
|
||||
}
|
||||
MenuAction::Paste => {
|
||||
let clip = clipboard
|
||||
.read(clipboard::Kind::Standard)
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect::<String>();
|
||||
let edit = paste(self.value, start, end, &clip);
|
||||
|
||||
self.publish_paste(edit, shell);
|
||||
}
|
||||
MenuAction::SelectAll => {
|
||||
let (start, end) = select_all_range(self.value);
|
||||
|
||||
self.input_state.select_range(start, end);
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_edit(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
|
||||
if let Some(on_input) = &self.on_input {
|
||||
self.input_state.move_cursor_to(edit.cursor);
|
||||
shell.publish(on_input.as_ref()(edit.value));
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_paste(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
|
||||
self.input_state.move_cursor_to(edit.cursor);
|
||||
|
||||
if let Some(on_paste) = &self.on_paste {
|
||||
shell.publish(on_paste.as_ref()(edit.value));
|
||||
} else if let Some(on_input) = &self.on_input {
|
||||
shell.publish(on_input.as_ref()(edit.value));
|
||||
}
|
||||
|
||||
shell.request_redraw();
|
||||
}
|
||||
|
||||
fn close(&mut self, shell: &mut Shell<'_, Message>) {
|
||||
*self.menu = None;
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn item_bounds(menu_bounds: Rectangle, index: usize) -> Rectangle {
|
||||
Rectangle {
|
||||
x: menu_bounds.x + 3.0,
|
||||
y: menu_bounds.y + 3.0 + ITEM_HEIGHT * index as f32,
|
||||
width: menu_bounds.width - 6.0,
|
||||
height: ITEM_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
fn input_style<Theme: text_input::Catalog>(
|
||||
theme: &Theme,
|
||||
style: Option<&InputStyleFn<'_, Theme>>,
|
||||
status: text_input::Status,
|
||||
) -> text_input::Style {
|
||||
if let Some(style) = style {
|
||||
style.as_ref()(theme, status)
|
||||
} else {
|
||||
let class = <Theme as text_input::Catalog>::default();
|
||||
|
||||
theme.style(&class, status)
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_color(color: Color) -> Color {
|
||||
Color {
|
||||
a: color.a * 0.45,
|
||||
..color
|
||||
}
|
||||
}
|
||||
|
||||
fn menu_action_enabled(
|
||||
action: MenuAction,
|
||||
has_selection: bool,
|
||||
has_value: bool,
|
||||
is_secure: bool,
|
||||
locked: bool,
|
||||
) -> bool {
|
||||
match action {
|
||||
MenuAction::Cut => has_selection && !is_secure && !locked,
|
||||
MenuAction::Copy => has_selection && !is_secure,
|
||||
MenuAction::Paste => !locked,
|
||||
MenuAction::SelectAll => has_value,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn copy_selection_returns_middle_substring_and_empty_none() {
|
||||
assert_eq!(copy_selection("abcdef", 2, 5), Some("cde".to_owned()));
|
||||
assert_eq!(copy_selection("abcdef", 3, 3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_selection_removes_range_and_copies_selection() {
|
||||
let (edit, clip) = cut_selection("abcdef", 2, 5);
|
||||
|
||||
assert_eq!(
|
||||
edit,
|
||||
Edit {
|
||||
value: "abf".to_owned(),
|
||||
cursor: 2,
|
||||
}
|
||||
);
|
||||
assert_eq!(clip, Some("cde".to_owned()));
|
||||
|
||||
let (edit, clip) = cut_selection("abcdef", 3, 3);
|
||||
assert_eq!(
|
||||
edit,
|
||||
Edit {
|
||||
value: "abcdef".to_owned(),
|
||||
cursor: 3,
|
||||
}
|
||||
);
|
||||
assert_eq!(clip, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_replaces_selection_or_inserts_at_cursor() {
|
||||
assert_eq!(
|
||||
paste("abcdef", 2, 5, "XY"),
|
||||
Edit {
|
||||
value: "abXYf".to_owned(),
|
||||
cursor: 4,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
paste("abcdef", 3, 3, "XY"),
|
||||
Edit {
|
||||
value: "abcXYdef".to_owned(),
|
||||
cursor: 5,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_range_uses_grapheme_length() {
|
||||
assert_eq!(select_all_range(""), (0, 0));
|
||||
assert_eq!(select_all_range("abé🦀"), (0, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_selection_boundaries_are_grapheme_correct() {
|
||||
assert_eq!(copy_selection("aé🦀z", 1, 3), Some("é🦀".to_owned()));
|
||||
|
||||
let (edit, clip) = cut_selection("aé🦀z", 2, 3);
|
||||
assert_eq!(clip, Some("🦀".to_owned()));
|
||||
assert_eq!(
|
||||
edit,
|
||||
Edit {
|
||||
value: "aéz".to_owned(),
|
||||
cursor: 2,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
paste("aéz", 2, 2, "🦀"),
|
||||
Edit {
|
||||
value: "aé🦀z".to_owned(),
|
||||
cursor: 3,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_menu_allows_copy_and_select_all_only() {
|
||||
assert!(!menu_action_enabled(MenuAction::Cut, true, true, false, true));
|
||||
assert!(menu_action_enabled(MenuAction::Copy, true, true, false, true));
|
||||
assert!(!menu_action_enabled(MenuAction::Paste, true, true, false, true));
|
||||
assert!(menu_action_enabled(MenuAction::SelectAll, true, true, false, true));
|
||||
|
||||
assert!(!menu_action_enabled(MenuAction::Copy, false, true, false, true));
|
||||
assert!(!menu_action_enabled(MenuAction::SelectAll, false, false, false, true));
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod context_input;
|
||||
pub mod selectable_text;
|
||||
@@ -1,890 +0,0 @@
|
||||
use iced::advanced::clipboard::{self, Clipboard};
|
||||
use iced::advanced::layout;
|
||||
use iced::advanced::mouse;
|
||||
use iced::advanced::renderer;
|
||||
use iced::advanced::text::{self as advanced_text, Paragraph, Span};
|
||||
use iced::advanced::widget::tree::{self, Tree};
|
||||
use iced::advanced::widget::Widget;
|
||||
use iced::advanced::{Layout, Shell};
|
||||
use iced::widget::text::{
|
||||
self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn,
|
||||
Wrapping,
|
||||
};
|
||||
use iced::{
|
||||
alignment, Background, Border, Color, Element, Event, Length, Pixels, Point,
|
||||
Rectangle, Size, Vector, keyboard,
|
||||
};
|
||||
|
||||
const DRAG_THRESHOLD: f32 = 3.0;
|
||||
const HIT_SEARCH_STEPS: usize = 24;
|
||||
|
||||
// Offsets here are paragraph-global BYTE offsets. `Paragraph::hit_test` returns
|
||||
// `Hit::CharOffset(cursor.index)`, and cosmic-text's `cursor.index` is a byte
|
||||
// offset WITHIN its buffer line — it discards the line number. That equals the
|
||||
// global byte offset only when the text is a single logical line. Chat bodies
|
||||
// satisfy this because `app::sanitize_chat` turns every control char (incl. `\n`
|
||||
// and `\r`) into a space and collapses whitespace, so a stored message can never
|
||||
// contain a newline. If that sanitizer ever starts preserving newlines, this
|
||||
// widget's per-line offsets would stop being global and selection/copy across
|
||||
// lines would break — revisit then.
|
||||
|
||||
pub fn selected_substring(
|
||||
text: &str,
|
||||
anchor: usize,
|
||||
cursor: usize,
|
||||
) -> Option<String> {
|
||||
let (start, end) = normalized_byte_range(text, anchor, cursor);
|
||||
|
||||
(start != end).then(|| text[start..end].to_owned())
|
||||
}
|
||||
|
||||
pub fn select_all(text: &str) -> (usize, usize) {
|
||||
(0, text.len())
|
||||
}
|
||||
|
||||
fn normalized_byte_range(
|
||||
text: &str,
|
||||
anchor: usize,
|
||||
cursor: usize,
|
||||
) -> (usize, usize) {
|
||||
let start = clamp_to_char_boundary(text, anchor.min(cursor));
|
||||
let end = clamp_to_char_boundary(text, anchor.max(cursor));
|
||||
|
||||
(start.min(end), start.max(end))
|
||||
}
|
||||
|
||||
fn clamp_to_char_boundary(text: &str, offset: usize) -> usize {
|
||||
let mut offset = offset.min(text.len());
|
||||
|
||||
while offset > 0 && !text.is_char_boundary(offset) {
|
||||
offset -= 1;
|
||||
}
|
||||
|
||||
offset
|
||||
}
|
||||
|
||||
pub fn selectable_rich_text<'a, Link, Message, Theme, Renderer>(
|
||||
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
|
||||
) -> SelectableRichText<'a, Link, Message, Theme, Renderer>
|
||||
where
|
||||
Link: Clone + 'static,
|
||||
Theme: Catalog + 'a,
|
||||
Renderer: advanced_text::Renderer,
|
||||
Renderer::Font: 'a,
|
||||
{
|
||||
SelectableRichText::with_spans(spans)
|
||||
}
|
||||
|
||||
pub struct SelectableRichText<
|
||||
'a,
|
||||
Link,
|
||||
Message,
|
||||
Theme = iced::Theme,
|
||||
Renderer = iced::Renderer,
|
||||
> where
|
||||
Link: Clone + 'static,
|
||||
Theme: Catalog,
|
||||
Renderer: advanced_text::Renderer,
|
||||
{
|
||||
spans: Box<dyn AsRef<[Span<'a, Link, Renderer::Font>]> + 'a>,
|
||||
size: Option<Pixels>,
|
||||
line_height: LineHeight,
|
||||
width: Length,
|
||||
height: Length,
|
||||
font: Option<Renderer::Font>,
|
||||
align_x: Alignment,
|
||||
align_y: alignment::Vertical,
|
||||
wrapping: Wrapping,
|
||||
class: Theme::Class<'a>,
|
||||
hovered_link: Option<usize>,
|
||||
on_link_click: Option<Box<dyn Fn(Link) -> Message + 'a>>,
|
||||
selection_color: Color,
|
||||
}
|
||||
|
||||
impl<'a, Link, Message, Theme, Renderer>
|
||||
SelectableRichText<'a, Link, Message, Theme, Renderer>
|
||||
where
|
||||
Link: Clone + 'static,
|
||||
Theme: Catalog,
|
||||
Renderer: advanced_text::Renderer,
|
||||
Renderer::Font: 'a,
|
||||
{
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
spans: Box::new([]),
|
||||
size: None,
|
||||
line_height: LineHeight::default(),
|
||||
width: Length::Shrink,
|
||||
height: Length::Shrink,
|
||||
font: None,
|
||||
align_x: Alignment::Default,
|
||||
align_y: alignment::Vertical::Top,
|
||||
wrapping: Wrapping::default(),
|
||||
class: Theme::default(),
|
||||
hovered_link: None,
|
||||
on_link_click: None,
|
||||
selection_color: Color::from_rgba(0.35, 0.55, 0.95, 0.35),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_spans(
|
||||
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
|
||||
) -> Self {
|
||||
Self {
|
||||
spans: Box::new(spans),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
|
||||
self.size = Some(size.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn line_height(mut self, line_height: impl Into<LineHeight>) -> Self {
|
||||
self.line_height = line_height.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
|
||||
self.font = Some(font.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn height(mut self, height: impl Into<Length>) -> Self {
|
||||
self.height = height.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn align_x(mut self, alignment: impl Into<Alignment>) -> Self {
|
||||
self.align_x = alignment.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn align_y(
|
||||
mut self,
|
||||
alignment: impl Into<alignment::Vertical>,
|
||||
) -> Self {
|
||||
self.align_y = alignment.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn wrapping(mut self, wrapping: Wrapping) -> Self {
|
||||
self.wrapping = wrapping;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_link_click(
|
||||
mut self,
|
||||
on_link_click: impl Fn(Link) -> Message + 'a,
|
||||
) -> Self {
|
||||
self.on_link_click = Some(Box::new(on_link_click));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn selection_color(mut self, color: Color) -> Self {
|
||||
self.selection_color = Color {
|
||||
a: color.a.min(0.35),
|
||||
..color
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self
|
||||
where
|
||||
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
|
||||
{
|
||||
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn color(self, color: impl Into<Color>) -> Self
|
||||
where
|
||||
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
|
||||
{
|
||||
self.color_maybe(Some(color))
|
||||
}
|
||||
|
||||
pub fn color_maybe(self, color: Option<impl Into<Color>>) -> Self
|
||||
where
|
||||
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
|
||||
{
|
||||
let color = color.map(Into::into);
|
||||
|
||||
self.style(move |_theme| Style { color })
|
||||
}
|
||||
|
||||
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
|
||||
self.class = class.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Link, Message, Theme, Renderer> Default
|
||||
for SelectableRichText<'a, Link, Message, Theme, Renderer>
|
||||
where
|
||||
Link: Clone + 'static,
|
||||
Theme: Catalog,
|
||||
Renderer: advanced_text::Renderer,
|
||||
Renderer::Font: 'a,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectableTextState<Link, P: Paragraph> {
|
||||
spans: Vec<Span<'static, Link, P::Font>>,
|
||||
span_pressed: Option<usize>,
|
||||
paragraph: P,
|
||||
selection: Option<(usize, usize)>,
|
||||
dragging: bool,
|
||||
active: bool,
|
||||
press_position: Option<Point>,
|
||||
}
|
||||
|
||||
impl<Link, P: Paragraph> SelectableTextState<Link, P> {
|
||||
fn selection_range(&self, text: &str) -> Option<(usize, usize)> {
|
||||
let (anchor, cursor) = self.selection?;
|
||||
let (start, end) = normalized_byte_range(text, anchor, cursor);
|
||||
|
||||
(start != end).then_some((start, end))
|
||||
}
|
||||
}
|
||||
|
||||
impl<Link, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for SelectableRichText<'_, Link, Message, Theme, Renderer>
|
||||
where
|
||||
Link: Clone + 'static,
|
||||
Theme: Catalog,
|
||||
Renderer: advanced_text::Renderer,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<SelectableTextState<Link, Renderer::Paragraph>>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(SelectableTextState::<Link, _> {
|
||||
spans: Vec::new(),
|
||||
span_pressed: None,
|
||||
paragraph: Renderer::Paragraph::default(),
|
||||
selection: None,
|
||||
dragging: false,
|
||||
active: false,
|
||||
press_position: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
layout_text(
|
||||
tree.state
|
||||
.downcast_mut::<SelectableTextState<Link, Renderer::Paragraph>>(),
|
||||
renderer,
|
||||
limits,
|
||||
TextLayout {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
spans: self.spans.as_ref().as_ref(),
|
||||
line_height: self.line_height,
|
||||
size: self.size,
|
||||
font: self.font,
|
||||
align_x: self.align_x,
|
||||
align_y: self.align_y,
|
||||
wrapping: self.wrapping,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
defaults: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
_cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if !layout.bounds().intersects(viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
let state = tree
|
||||
.state
|
||||
.downcast_ref::<SelectableTextState<Link, Renderer::Paragraph>>();
|
||||
let spans = self.spans.as_ref().as_ref();
|
||||
let flat_text = flatten_spans(spans);
|
||||
let style = theme.style(&self.class);
|
||||
let translation = layout.position() - Point::ORIGIN;
|
||||
|
||||
if let Some((start, end)) = state.selection_range(&flat_text) {
|
||||
let mut rects = selection_rects(&state.paragraph, spans.len(), start, end);
|
||||
if rects.is_empty() {
|
||||
rects = visual_lines(&state.paragraph, spans.len());
|
||||
}
|
||||
|
||||
for bounds in rects {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: bounds + translation,
|
||||
border: Border {
|
||||
radius: 2.0.into(),
|
||||
..Border::default()
|
||||
},
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
Background::Color(self.selection_color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (index, span) in spans.iter().enumerate() {
|
||||
let is_hovered_link = self.on_link_click.is_some()
|
||||
&& Some(index) == self.hovered_link;
|
||||
|
||||
if span.highlight.is_some()
|
||||
|| span.underline
|
||||
|| span.strikethrough
|
||||
|| is_hovered_link
|
||||
{
|
||||
let regions = state.paragraph.span_bounds(index);
|
||||
|
||||
if let Some(highlight) = span.highlight {
|
||||
for bounds in ®ions {
|
||||
let bounds = Rectangle::new(
|
||||
bounds.position()
|
||||
- Vector::new(
|
||||
span.padding.left,
|
||||
span.padding.top,
|
||||
),
|
||||
bounds.size()
|
||||
+ Size::new(span.padding.x(), span.padding.y()),
|
||||
);
|
||||
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: bounds + translation,
|
||||
border: highlight.border,
|
||||
..Default::default()
|
||||
},
|
||||
highlight.background,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if span.underline || span.strikethrough || is_hovered_link {
|
||||
let size = span
|
||||
.size
|
||||
.or(self.size)
|
||||
.unwrap_or(renderer.default_size());
|
||||
|
||||
let line_height = span
|
||||
.line_height
|
||||
.unwrap_or(self.line_height)
|
||||
.to_absolute(size);
|
||||
|
||||
let color = span
|
||||
.color
|
||||
.or(style.color)
|
||||
.unwrap_or(defaults.text_color);
|
||||
|
||||
let baseline = translation
|
||||
+ Vector::new(
|
||||
0.0,
|
||||
size.0 + (line_height.0 - size.0) / 2.0,
|
||||
);
|
||||
|
||||
if span.underline || is_hovered_link {
|
||||
for bounds in ®ions {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: Rectangle::new(
|
||||
bounds.position() + baseline
|
||||
- Vector::new(0.0, size.0 * 0.08),
|
||||
Size::new(bounds.width, 1.0),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if span.strikethrough {
|
||||
for bounds in ®ions {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: Rectangle::new(
|
||||
bounds.position() + baseline
|
||||
- Vector::new(0.0, size.0 / 2.0),
|
||||
Size::new(bounds.width, 1.0),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
widget_text::draw(
|
||||
renderer,
|
||||
defaults,
|
||||
layout.bounds(),
|
||||
&state.paragraph,
|
||||
style,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
_viewport: &Rectangle,
|
||||
) {
|
||||
let bounds = layout.bounds();
|
||||
let local_position = cursor.position_in(bounds);
|
||||
let state = tree
|
||||
.state
|
||||
.downcast_mut::<SelectableTextState<Link, Renderer::Paragraph>>();
|
||||
let spans = self.spans.as_ref().as_ref();
|
||||
let flat_text = flatten_spans(spans);
|
||||
|
||||
let was_hovered = self.hovered_link.is_some();
|
||||
self.hovered_link = local_position.and_then(|position| {
|
||||
state.paragraph.hit_span(position).and_then(|span| {
|
||||
if spans.get(span)?.link.is_some() {
|
||||
Some(span)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if was_hovered != self.hovered_link.is_some() {
|
||||
shell.request_redraw();
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
if let Some(position) = local_position {
|
||||
state.active = true;
|
||||
state.dragging = true;
|
||||
state.press_position = Some(position);
|
||||
state.span_pressed = self.hovered_link;
|
||||
state.selection = state
|
||||
.paragraph
|
||||
.hit_test(position)
|
||||
.map(|hit| {
|
||||
let offset = hit.cursor().min(flat_text.len());
|
||||
(offset, offset)
|
||||
});
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
} else if state.active || state.selection.is_some() {
|
||||
state.active = false;
|
||||
state.dragging = false;
|
||||
state.press_position = None;
|
||||
state.span_pressed = None;
|
||||
state.selection = None;
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
||||
if state.dragging
|
||||
&& let Some(position) = clamped_position(cursor, bounds)
|
||||
&& let Some(hit) = state.paragraph.hit_test(position)
|
||||
&& let Some((anchor, _)) = state.selection
|
||||
{
|
||||
state.selection =
|
||||
Some((anchor, hit.cursor().min(flat_text.len())));
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
if state.dragging {
|
||||
let release_position = clamped_position(cursor, bounds)
|
||||
.or(local_position)
|
||||
.or(state.press_position);
|
||||
let dragged = state
|
||||
.press_position
|
||||
.zip(release_position)
|
||||
.is_some_and(|(start, end)| point_distance(start, end) > DRAG_THRESHOLD);
|
||||
|
||||
if let Some(position) = release_position
|
||||
&& let Some(hit) = state.paragraph.hit_test(position)
|
||||
&& let Some((anchor, _)) = state.selection
|
||||
{
|
||||
state.selection =
|
||||
Some((anchor, hit.cursor().min(flat_text.len())));
|
||||
}
|
||||
|
||||
if !dragged {
|
||||
if let (Some(on_link_clicked), Some(span)) =
|
||||
(&self.on_link_click, state.span_pressed)
|
||||
&& Some(span) == self.hovered_link
|
||||
&& let Some(link) =
|
||||
spans.get(span).and_then(|span| span.link.clone())
|
||||
{
|
||||
shell.publish(on_link_clicked(link));
|
||||
}
|
||||
state.selection = None;
|
||||
} else if state.selection_range(&flat_text).is_none() {
|
||||
state.selection = None;
|
||||
}
|
||||
|
||||
state.dragging = false;
|
||||
state.span_pressed = None;
|
||||
state.press_position = None;
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
physical_key,
|
||||
modifiers,
|
||||
..
|
||||
}) if state.active && modifiers.command() => {
|
||||
match key.to_latin(*physical_key) {
|
||||
Some('c') | Some('C') => {
|
||||
if let Some((anchor, cursor)) = state.selection
|
||||
&& let Some(selected) =
|
||||
selected_substring(&flat_text, anchor, cursor)
|
||||
{
|
||||
clipboard.write(clipboard::Kind::Standard, selected);
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
Some('a') | Some('A') => {
|
||||
state.selection = Some(select_all(&flat_text));
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
_renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
let state = tree
|
||||
.state
|
||||
.downcast_ref::<SelectableTextState<Link, Renderer::Paragraph>>();
|
||||
|
||||
if state.dragging {
|
||||
mouse::Interaction::Text
|
||||
} else if self.hovered_link.is_some() {
|
||||
mouse::Interaction::Pointer
|
||||
} else if cursor.is_over(layout.bounds()) {
|
||||
mouse::Interaction::Text
|
||||
} else {
|
||||
mouse::Interaction::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TextLayout<'a, 'span, Link, Font> {
|
||||
width: Length,
|
||||
height: Length,
|
||||
spans: &'a [Span<'span, Link, Font>],
|
||||
line_height: LineHeight,
|
||||
size: Option<Pixels>,
|
||||
font: Option<Font>,
|
||||
align_x: Alignment,
|
||||
align_y: alignment::Vertical,
|
||||
wrapping: Wrapping,
|
||||
}
|
||||
|
||||
fn layout_text<Link, Renderer>(
|
||||
state: &mut SelectableTextState<Link, Renderer::Paragraph>,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
config: TextLayout<'_, '_, Link, Renderer::Font>,
|
||||
) -> layout::Node
|
||||
where
|
||||
Link: Clone,
|
||||
Renderer: advanced_text::Renderer,
|
||||
{
|
||||
layout::sized(limits, config.width, config.height, |limits| {
|
||||
let bounds = limits.max();
|
||||
let size = config.size.unwrap_or_else(|| renderer.default_size());
|
||||
let font = config.font.unwrap_or_else(|| renderer.default_font());
|
||||
|
||||
let text_with_spans = || advanced_text::Text {
|
||||
content: config.spans,
|
||||
bounds,
|
||||
size,
|
||||
line_height: config.line_height,
|
||||
font,
|
||||
align_x: config.align_x,
|
||||
align_y: config.align_y,
|
||||
shaping: Shaping::Advanced,
|
||||
wrapping: config.wrapping,
|
||||
};
|
||||
|
||||
if state.spans != config.spans {
|
||||
state.paragraph =
|
||||
Renderer::Paragraph::with_spans(text_with_spans());
|
||||
state.spans = config
|
||||
.spans
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(Span::to_static)
|
||||
.collect();
|
||||
} else {
|
||||
match state.paragraph.compare(advanced_text::Text {
|
||||
content: (),
|
||||
bounds,
|
||||
size,
|
||||
line_height: config.line_height,
|
||||
font,
|
||||
align_x: config.align_x,
|
||||
align_y: config.align_y,
|
||||
shaping: Shaping::Advanced,
|
||||
wrapping: config.wrapping,
|
||||
}) {
|
||||
advanced_text::Difference::None => {}
|
||||
advanced_text::Difference::Bounds => {
|
||||
state.paragraph.resize(bounds);
|
||||
}
|
||||
advanced_text::Difference::Shape => {
|
||||
state.paragraph =
|
||||
Renderer::Paragraph::with_spans(text_with_spans());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.paragraph.min_bounds()
|
||||
})
|
||||
}
|
||||
|
||||
fn flatten_spans<Link, Font>(spans: &[Span<'_, Link, Font>]) -> String {
|
||||
spans
|
||||
.iter()
|
||||
.map(|span| span.text.as_ref())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn selection_rects<P: Paragraph>(
|
||||
paragraph: &P,
|
||||
span_count: usize,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> Vec<Rectangle> {
|
||||
visual_lines(paragraph, span_count)
|
||||
.into_iter()
|
||||
.filter_map(|line| selection_rect_for_line(paragraph, line, start, end))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn selection_rect_for_line<P: Paragraph>(
|
||||
paragraph: &P,
|
||||
line: Rectangle,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> Option<Rectangle> {
|
||||
let y = line.center_y();
|
||||
let paragraph_width = paragraph.bounds().width.max(line.x + line.width + 1.0);
|
||||
let left_probe = line.x.max(0.0);
|
||||
let right_probe = (line.x + line.width + 1.0).min(paragraph_width.max(1.0));
|
||||
let line_start = paragraph
|
||||
.hit_test(Point::new(left_probe, y))
|
||||
.map(advanced_text::Hit::cursor)?;
|
||||
let line_end = paragraph
|
||||
.hit_test(Point::new(right_probe, y))
|
||||
.map(advanced_text::Hit::cursor)
|
||||
.unwrap_or(line_start);
|
||||
let (line_start, line_end) = if line_start <= line_end {
|
||||
(line_start, line_end)
|
||||
} else {
|
||||
(line_end, line_start)
|
||||
};
|
||||
let overlap_start = start.max(line_start);
|
||||
let overlap_end = end.min(line_end);
|
||||
|
||||
if overlap_start >= overlap_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
let x_start = if overlap_start <= line_start {
|
||||
line.x
|
||||
} else {
|
||||
x_for_offset(paragraph, y, overlap_start, line.x, line.x + line.width)
|
||||
};
|
||||
let x_end = if overlap_end >= line_end {
|
||||
line.x + line.width
|
||||
} else {
|
||||
x_for_offset(paragraph, y, overlap_end, line.x, line.x + line.width)
|
||||
};
|
||||
let left = x_start.min(x_end);
|
||||
let right = x_start.max(x_end);
|
||||
|
||||
(right > left).then(|| {
|
||||
Rectangle::new(
|
||||
Point::new(left, line.y),
|
||||
Size::new(right - left, line.height),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn x_for_offset<P: Paragraph>(
|
||||
paragraph: &P,
|
||||
y: f32,
|
||||
offset: usize,
|
||||
left: f32,
|
||||
right: f32,
|
||||
) -> f32 {
|
||||
let mut low = left;
|
||||
let mut high = right.max(left);
|
||||
|
||||
for _ in 0..HIT_SEARCH_STEPS {
|
||||
let mid = (low + high) / 2.0;
|
||||
let hit = paragraph
|
||||
.hit_test(Point::new(mid, y))
|
||||
.map(advanced_text::Hit::cursor);
|
||||
|
||||
match hit {
|
||||
Some(hit) if hit < offset => low = mid,
|
||||
Some(_) => high = mid,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
high
|
||||
}
|
||||
|
||||
fn visual_lines<P: Paragraph>(
|
||||
paragraph: &P,
|
||||
span_count: usize,
|
||||
) -> Vec<Rectangle> {
|
||||
let mut lines: Vec<Rectangle> = Vec::new();
|
||||
|
||||
for span in 0..span_count {
|
||||
for bounds in paragraph.span_bounds(span) {
|
||||
if bounds.width <= 0.0 || bounds.height <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(line) = lines
|
||||
.iter_mut()
|
||||
.find(|line| (line.center_y() - bounds.center_y()).abs() < 1.0)
|
||||
{
|
||||
*line = union(*line, bounds);
|
||||
} else {
|
||||
lines.push(bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.sort_by(|a, b| a.y.total_cmp(&b.y));
|
||||
lines
|
||||
}
|
||||
|
||||
fn union(a: Rectangle, b: Rectangle) -> Rectangle {
|
||||
let left = a.x.min(b.x);
|
||||
let top = a.y.min(b.y);
|
||||
let right = (a.x + a.width).max(b.x + b.width);
|
||||
let bottom = (a.y + a.height).max(b.y + b.height);
|
||||
|
||||
Rectangle::new(
|
||||
Point::new(left, top),
|
||||
Size::new(right - left, bottom - top),
|
||||
)
|
||||
}
|
||||
|
||||
fn clamped_position(cursor: mouse::Cursor, bounds: Rectangle) -> Option<Point> {
|
||||
cursor.position_from(bounds.position()).map(|position| {
|
||||
Point::new(
|
||||
position.x.clamp(0.0, bounds.width.max(1.0) - 1.0),
|
||||
position.y.clamp(0.0, bounds.height.max(1.0) - 1.0),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn point_distance(a: Point, b: Point) -> f32 {
|
||||
let dx = a.x - b.x;
|
||||
let dy = a.y - b.y;
|
||||
|
||||
(dx * dx + dy * dy).sqrt()
|
||||
}
|
||||
|
||||
impl<'a, Link, Message, Theme, Renderer>
|
||||
From<SelectableRichText<'a, Link, Message, Theme, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Link: Clone + 'a,
|
||||
Theme: Catalog + 'a,
|
||||
Renderer: advanced_text::Renderer + 'a,
|
||||
{
|
||||
fn from(
|
||||
text: SelectableRichText<'a, Link, Message, Theme, Renderer>,
|
||||
) -> Element<'a, Message, Theme, Renderer> {
|
||||
Element::new(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selected_substring_returns_middle_and_none_for_collapsed() {
|
||||
assert_eq!(selected_substring("abcdef", 2, 5), Some("cde".to_owned()));
|
||||
assert_eq!(selected_substring("abcdef", 3, 3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_substring_handles_reversed_range() {
|
||||
assert_eq!(selected_substring("abcdef", 5, 2), Some("cde".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_uses_byte_length() {
|
||||
assert_eq!(select_all(""), (0, 0));
|
||||
assert_eq!(select_all("aé👍z"), (0, "aé👍z".len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_selection_uses_byte_offsets_without_panicking() {
|
||||
let text = "aé👍z";
|
||||
|
||||
assert_eq!(selected_substring(text, 1, 7), Some("é👍".to_owned()));
|
||||
assert_eq!(selected_substring(text, 3, 7), Some("👍".to_owned()));
|
||||
assert_eq!(selected_substring(text, 2, 7), Some("é👍".to_owned()));
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,6 @@ fn state(name: &str, addr: EndpointAddr) -> PeerState {
|
||||
sharing: None,
|
||||
avatar: Default::default(),
|
||||
game: None,
|
||||
music: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user