Compare commits

..
8 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.8 7e4f2f2127 fix(chat): use async save dialog for attachments
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The Save-attachment handler called the blocking rfd::FileDialog::save_file()
directly inside iced's update() loop. That blocking dialog spins its own GTK
loop; invoked from within iced's already-running event loop (notably the Linux
xdg-desktop-portal/GTK backend, but also observed wedged on Windows) the dialog
becomes unresponsive — Save/Cancel clicks are never processed.

Convert to rfd::AsyncFileDialog returning a Task, mirroring the existing file
*picker* paths (PickAttachmentFile / PickAvatarFile / PickBackgroundFile) which
already use the async variant. The chosen path's bytes are written when the
future resolves; the status line is reported via a new AttachmentSaved message.
No blocking call remains in the update loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:43:12 -04:00
mollusk b6eac330ca Update package version for 0.3.0
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-21 02:03:07 -04:00
mollusk 7fb1c96ca9 Redesign PeerSpeak application icon
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-21 02:02:37 -04:00
mollusk 80a5b73e39 Merge inline chat audio player
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-21 01:45:43 -04:00
mollusk adf7d1c0e2 Fix inline audio duration detection 2026-06-21 01:41:57 -04:00
mollusk bcb597a0ea Add inline chat audio player 2026-06-21 01:05:24 -04:00
molluskandClaude Opus 4.8 79b24fd567 deps: add rodio for inline chat audio playback (senior-vetted)
Pre-stages the playback dependency for the inline chat audio player task so
Codex can build it in its network-off sandbox.

rodio 0.22.2 decodes wav/mp3/ogg(vorbis)/flac (via bundled symphonia) and
handles output + play/pause/seek + resampling. It brings its own cpal 0.17
(the project's PipeWire/cpal-0.15 call path is untouched; rodio's output is a
separate stream on the system default device) and alsa on Linux.

Supply chain: cargo audit reports NO new advisories from this subtree -- the
only 2 warnings (audiopus_sys, paste) are pre-existing, unmaintained-only, and
already on the allow-list. Builds clean (release).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:52:55 -04:00
molluskandClaude Opus 4.8 efadc228eb fix(files): keep serve connection alive until fetcher has the bytes
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Chat file fetches failed 100% of the time with "file fetch: read failed:
read error: connection lost" (both images and arbitrary files, both
directions). Root cause: the FileRouter serve handler called send.finish()
and immediately returned Ok(()), which dropped the Connection. In QUIC,
finish() only marks the stream's EOF -- it does not wait for the written
bytes to be delivered and acknowledged -- so the connection's
CONNECTION_CLOSE raced ahead of the still-in-flight stream data and the
fetcher's read_to_end aborted.

Fix: after finishing, wait on connection.closed() (bounded by
FILE_FETCH_TIMEOUT) so the link stays up until the fetcher has read
everything and closed the connection itself, which is the signal the
transfer landed.

Wire-compatible (no protocol change), so version stays 0.3.0; both peers
just need the rebuilt binary since either side can be the file server.

Adds tests/file_transfer_loopback.rs: a real two-endpoint serve->fetch
round-trip over FILES_ALPN with a 2 MiB multi-packet blob (deterministic
A/B: 0/20 pass without the fix, 20/20 with it) plus an unknown-id "gone"
case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:07:23 -04:00
20 changed files with 1119 additions and 56 deletions
Generated
+313 -4
View File
@@ -117,6 +117,18 @@ dependencies = [
"libc",
]
[[package]]
name = "alsa"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3"
dependencies = [
"alsa-sys",
"bitflags 2.11.1",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
@@ -1041,6 +1053,20 @@ dependencies = [
"coreaudio-sys",
]
[[package]]
name = "coreaudio-rs"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
dependencies = [
"bitflags 1.3.2",
"libc",
"objc2-audio-toolbox",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
]
[[package]]
name = "coreaudio-sys"
version = "0.2.18"
@@ -1080,14 +1106,14 @@ version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"alsa 0.9.1",
"core-foundation-sys",
"coreaudio-rs",
"coreaudio-rs 0.11.3",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
"mach2 0.4.3",
"ndk 0.8.0",
"ndk-context",
"oboe",
@@ -1097,6 +1123,36 @@ dependencies = [
"windows 0.54.0",
]
[[package]]
name = "cpal"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
dependencies = [
"alsa 0.10.0",
"coreaudio-rs 0.13.0",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2 0.5.0",
"ndk 0.9.0",
"ndk-context",
"num-derive",
"num-traits",
"objc2 0.6.4",
"objc2-audio-toolbox",
"objc2-avf-audio",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.62.2",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1564,6 +1620,15 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1699,6 +1764,12 @@ dependencies = [
"zune-inflate",
]
[[package]]
name = "extended"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -2557,6 +2628,7 @@ dependencies = [
"iced_core",
"log",
"rustc-hash 2.1.2",
"tokio",
"wasm-bindgen-futures",
"wasmtimer",
]
@@ -3565,6 +3637,15 @@ dependencies = [
"libc",
]
[[package]]
name = "mach2"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
dependencies = [
"libc",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
@@ -4252,6 +4333,31 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "objc2-audio-toolbox"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
dependencies = [
"bitflags 2.11.1",
"libc",
"objc2 0.6.4",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-avf-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
dependencies = [
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.2.2"
@@ -4287,6 +4393,29 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-core-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
dependencies = [
"dispatch2",
"objc2 0.6.4",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-audio-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
dependencies = [
"bitflags 2.11.1",
"objc2 0.6.4",
]
[[package]]
name = "objc2-core-data"
version = "0.2.2"
@@ -4748,7 +4877,7 @@ dependencies = [
"async-trait",
"base64",
"bytes",
"cpal",
"cpal 0.15.3",
"dirs",
"iced",
"image",
@@ -4759,6 +4888,7 @@ dependencies = [
"rand 0.10.1",
"rfd",
"ringbuf",
"rodio",
"serde",
"serde_json",
"thiserror 2.0.18",
@@ -5198,6 +5328,16 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_distr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
dependencies = [
"num-traits",
"rand 0.10.1",
]
[[package]]
name = "rand_pcg"
version = "0.10.2"
@@ -5487,12 +5627,34 @@ dependencies = [
"portable-atomic-util",
]
[[package]]
name = "rodio"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb"
dependencies = [
"cpal 0.17.1",
"dasp_sample",
"num-rational",
"rand 0.10.1",
"rand_distr",
"rtrb",
"symphonia",
"thiserror 2.0.18",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rtrb"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153"
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -6156,6 +6318,153 @@ dependencies = [
"zeno",
]
[[package]]
name = "symphonia"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
dependencies = [
"lazy_static",
"symphonia-bundle-flac",
"symphonia-bundle-mp3",
"symphonia-codec-aac",
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
"symphonia-format-isomp4",
"symphonia-format-ogg",
"symphonia-format-riff",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-flac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-codec-aac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-vorbis"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
dependencies = [
"log",
"symphonia-core",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-format-isomp4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
dependencies = [
"encoding_rs",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-ogg"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-riff"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
dependencies = [
"extended",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
dependencies = [
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "syn"
version = "2.0.117"
+2 -1
View File
@@ -28,7 +28,7 @@ async-trait = "0.1.89"
base64 = "0.22.1"
bytes = "1.11.1"
dirs = "6.0.0"
iced = { version = "0.14.0", features = ["canvas", "image"] }
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).
@@ -38,6 +38,7 @@ iroh-gossip = "0.99.0"
opus = "0.3.1"
rand = "0.10.1"
ringbuf = "0.5.0"
rodio = "0.22.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
thiserror = "2.0.18"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 B

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

+56 -31
View File
@@ -1,41 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<!-- PeerSpeak app icon: in-app mic glyph + P2P mesh nodes, Catppuccin Mocha. -->
<title>PeerSpeak</title>
<desc>Two luminous voices meet directly to form a flowing S.</desc>
<defs>
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1e1e2e"/>
<stop offset="1" stop-color="#181825"/>
<linearGradient id="tile" x1="32" y1="20" x2="225" y2="239" gradientUnits="userSpaceOnUse">
<stop stop-color="#101d42"/>
<stop offset="0.5" stop-color="#071225"/>
<stop offset="1" stop-color="#160b31"/>
</linearGradient>
<linearGradient id="voice" x1="45" y1="76" x2="214" y2="184" gradientUnits="userSpaceOnUse">
<stop stop-color="#35efff"/>
<stop offset="0.42" stop-color="#2583ff"/>
<stop offset="0.68" stop-color="#8a42ff"/>
<stop offset="1" stop-color="#ff3cdd"/>
</linearGradient>
<linearGradient id="edge" x1="30" y1="31" x2="225" y2="231" gradientUnits="userSpaceOnUse">
<stop stop-color="#2fe9ff" stop-opacity="0.7"/>
<stop offset="0.48" stop-color="#386dff" stop-opacity="0.18"/>
<stop offset="1" stop-color="#eb42ff" stop-opacity="0.65"/>
</linearGradient>
<radialGradient id="core">
<stop stop-color="#ffffff"/>
<stop offset="0.28" stop-color="#baf7ff"/>
<stop offset="0.62" stop-color="#7b67ff" stop-opacity="0.65"/>
<stop offset="1" stop-color="#7b67ff" stop-opacity="0"/>
</radialGradient>
<filter id="shadow" x="-35%" y="-35%" width="170%" height="170%">
<feGaussianBlur stdDeviation="6"/>
</filter>
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="7" stdDeviation="7" flood-color="#000611" flood-opacity="0.8"/>
</filter>
</defs>
<!-- Rounded-square tile -->
<rect x="20" y="20" width="216" height="216" rx="48" fill="url(#tile)"
stroke="#313244" stroke-width="3"/>
<!-- A dark stage makes the cyan/violet conversation mark legible at taskbar size. -->
<rect x="8" y="8" width="240" height="240" rx="55" fill="url(#tile)"/>
<rect x="9.5" y="9.5" width="237" height="237" rx="53.5" fill="none" stroke="url(#edge)" stroke-width="3"/>
<!-- P2P mesh: edges (under nodes + mic) -->
<g stroke="#45475a" stroke-width="6" stroke-linecap="round" fill="none">
<line x1="74" y1="74" x2="128" y2="128"/>
<line x1="182" y1="74" x2="128" y2="128"/>
<line x1="74" y1="182" x2="128" y2="128"/>
<line x1="182" y1="182" x2="128" y2="128"/>
<line x1="74" y1="74" x2="182" y2="74"/>
<line x1="74" y1="182" x2="182" y2="182"/>
</g>
<!-- Broad color glow, kept behind the silhouette. -->
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
fill="none" stroke="url(#voice)" stroke-width="25" stroke-linecap="round"
opacity="0.5" filter="url(#shadow)"/>
<!-- P2P mesh: peer nodes -->
<g fill="#b4befe">
<circle cx="74" cy="74" r="11"/>
<circle cx="182" cy="74" r="11"/>
<circle cx="74" cy="182" r="11"/>
<circle cx="182" cy="182" r="11"/>
</g>
<!-- The two waveform halves are equal peers and meet at one bright point. -->
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128"
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Microphone (hero) — same geometry as the in-app Mic icon, scaled 6.4x -->
<g fill="none" stroke="#89b4fa" stroke-width="13"
stroke-linecap="round" stroke-linejoin="round">
<rect x="108.8" y="68.8" width="38.4" height="70.4" rx="19.2"/>
<path d="M 169.6 123.2 A 41.6 41.6 0 0 0 86.4 123.2"/>
<line x1="128" y1="164.8" x2="128" y2="187.2"/>
<line x1="105.6" y1="187.2" x2="150.4" y2="187.2"/>
</g>
<!-- A single flowing connection turns the conversation into PeerSpeak's S-mark. -->
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
fill="none" stroke="#050b1b" stroke-opacity="0.72" stroke-width="33"
stroke-linecap="round" stroke-linejoin="round" filter="url(#soft-shadow)"/>
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
fill="none" stroke="url(#voice)" stroke-width="25"
stroke-linecap="round" stroke-linejoin="round"/>
<path d="M157 65 C139 56 113 62 108 79" fill="none" stroke="#bdf9ff"
stroke-opacity="0.68" stroke-width="4" stroke-linecap="round"/>
<path d="M153 166 C146 184 118 192 98 181" fill="none" stroke="#f4a8ff"
stroke-opacity="0.52" stroke-width="4" stroke-linecap="round"/>
<!-- The direct connection is the brightest and simplest detail. -->
<circle cx="128" cy="128" r="30" fill="url(#core)" opacity="0.78" filter="url(#shadow)"/>
<circle cx="128" cy="128" r="6.5" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
pkgname=peerspeak-git
_pkgname=peerspeak
pkgver=0.2.0.r218.gcbba4b6
pkgver=0.3.0.r229.g7fb1c96
pkgrel=1
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
arch=('x86_64')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

+217 -19
View File
@@ -2,6 +2,10 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
use crate::network::PeerState;
use crate::notify::{self, Sound};
use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN};
use crate::audio::clip_player::{
ClipPlayer, SharedClipStatus, format_time as format_clip_time, progress as clip_progress,
seek_target, status_snapshot,
};
use crate::audio::{AudioDevice, enumerate_audio_devices};
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
@@ -283,6 +287,15 @@ pub enum AppMessage {
AttachmentFilePicked(Option<(String, Vec<u8>)>),
/// Save (downloading first if needed) a received attachment to disk.
SaveAttachment(crate::files::AttachmentId),
/// Result of the async save dialog: a status line to show, or None if cancelled.
AttachmentSaved(Option<String>),
/// Fetch (if needed) and start an inline audio attachment.
PlayAudio(crate::files::AttachmentId),
PauseAudio,
ResumeAudio,
SeekAudio(crate::files::AttachmentId, f32),
/// Redraw cadence while an inline clip is active.
AudioTick,
/// Send the current chat input line (Enter or the Send button).
ChatSubmit,
/// Open a clicked chat link in the system browser (A13).
@@ -390,6 +403,15 @@ pub struct AppState {
/// Attachment ids the user asked to save before the bytes arrived; when the
/// fetch completes a save dialog is opened for them.
pending_saves: std::collections::HashSet<crate::files::AttachmentId>,
/// Clip ids waiting for the existing attachment fetch path to return bytes.
pending_plays: HashSet<crate::files::AttachmentId>,
/// Filename-hinted audio whose fetched bytes or decoder validation failed;
/// these entries fall back to the normal file chip.
invalid_audio: HashSet<crate::files::AttachmentId>,
/// Independent system-default-device player for chat clips. It never enters
/// the call capture/mixer path.
clip_player: ClipPlayer,
clip_status: SharedClipStatus,
/// Last known window size, tracked so divider clamps stay valid on resize.
/// (The divider positions themselves are persisted in `config`.)
window_size: Size,
@@ -522,6 +544,7 @@ impl Default for AppState {
let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
let background_image = load_background_bytes(&config);
let (clip_player, clip_status) = ClipPlayer::new();
Self {
// Pre-fill the nickname with the last one used (or "Peer" by default).
@@ -552,6 +575,10 @@ impl Default for AppState {
attachment_data: HashMap::new(),
image_handle_cache: HashMap::new(),
pending_saves: std::collections::HashSet::new(),
pending_plays: HashSet::new(),
invalid_audio: HashSet::new(),
clip_player,
clip_status,
chat_input: String::new(),
window_size: Size::new(ww, wh),
layout_picker_open: false,
@@ -676,10 +703,15 @@ fn initial_window_position(
}
}
fn subscription(_state: &AppState) -> Subscription<AppMessage> {
fn subscription(state: &AppState) -> Subscription<AppMessage> {
let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived);
let event_sub = iced::event::listen().map(AppMessage::EventOccurred);
Subscription::batch(vec![core_sub, event_sub])
let audio_sub = if status_snapshot(&state.clip_status).playing_id.is_some() {
iced::time::every(std::time::Duration::from_millis(250)).map(|_| AppMessage::AudioTick)
} else {
Subscription::none()
};
Subscription::batch(vec![core_sub, event_sub, audio_sub])
}
fn shutdown_timeout_task() -> Task<AppMessage> {
@@ -951,6 +983,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
notify::play(Sound::SelfJoin, state.config.custom_sound_self_join.as_deref());
}
UiEvent::RoomLeft => {
state.clip_player.stop();
state.ticket = "".to_string();
state.peers.clear();
state.audio_levels.clear();
@@ -960,6 +993,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.recording_started = None;
state.chat_messages.clear();
state.chat_input.clear();
state.attachment_data.clear();
state.image_handle_cache.clear();
state.pending_saves.clear();
state.pending_plays.clear();
state.invalid_audio.clear();
state.connecting.clear();
state.ever_connected.clear();
state.status_message = "Ready to connect".to_string();
@@ -1056,13 +1094,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
);
}
let needs_save = state.pending_saves.remove(&id);
let needs_play = state.pending_plays.remove(&id);
state.attachment_data.insert(id, AttachmentState::Ready(data));
if needs_play {
play_ready_audio(state, id);
}
if needs_save {
save_attachment_to_disk(state, id);
return save_attachment_task(state, id);
}
}
UiEvent::AttachmentFailed { id, error } => {
state.pending_saves.remove(&id);
state.pending_plays.remove(&id);
state.attachment_data.insert(id, AttachmentState::Failed(error.clone()));
state.status_message = format!("Attachment failed: {error}");
}
@@ -1622,7 +1665,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// If we already have the bytes, save now; otherwise fetch from the
// sender and save when AttachmentReady arrives (pending_saves).
if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) {
save_attachment_to_disk(state, id);
return save_attachment_task(state, id);
} else if let Some((from, att)) = find_attachment_source(state, id) {
if let Ok(eid) = from.parse::<EndpointId>() {
state.pending_saves.insert(id);
@@ -1635,6 +1678,50 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
}
}
}
AppMessage::AttachmentSaved(msg) => {
if let Some(m) = msg {
state.status_message = m;
}
}
AppMessage::PlayAudio(id) => {
if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) {
play_ready_audio(state, id);
} else if let Some((from, att)) = find_attachment_source(state, id) {
if let Ok(eid) = from.parse::<EndpointId>() {
// Repeated clicks while the transfer is pending must not
// launch duplicate fetches.
if state.pending_plays.insert(id) {
state.status_message = format!("Loading {}", att.name);
let _ = state
.controller
.send(CoreCommand::FetchAttachment { from: eid, attachment: att });
}
} else {
state.status_message = "Can't play: unknown sender.".to_string();
}
}
}
AppMessage::PauseAudio => state.clip_player.pause(),
AppMessage::ResumeAudio => state.clip_player.resume(),
AppMessage::SeekAudio(id, fraction) => {
let clip = status_snapshot(&state.clip_status);
if clip.playing_id == Some(id)
&& let Some(total) = clip.total
{
state.clip_player.seek(seek_target(fraction, total));
}
}
AppMessage::AudioTick => {
let clip = status_snapshot(&state.clip_status);
if let Some(failure) = clip.failure {
if failure.invalid_audio {
state.invalid_audio.insert(failure.id);
}
state.pending_plays.remove(&failure.id);
state.status_message = format!("Audio playback failed: {}", failure.error);
state.clip_player.stop();
}
}
AppMessage::OpenUrl(url) => {
// Defence in depth: only ever hand http(s) URLs to the opener. The
// link span's href came from `linkify`, which only emits http/https,
@@ -1885,14 +1972,36 @@ fn find_attachment_source(
})
}
/// Write a ready attachment's bytes to a user-chosen location via a native save
/// dialog. The default filename comes from the (already-sanitized) descriptor.
/// No-op if the bytes aren't ready. Sync dialog: the brief block is acceptable
/// for a deliberate save action.
fn save_attachment_to_disk(state: &mut AppState, id: crate::files::AttachmentId) {
/// Validate cached bytes and hand them to the independent clip player. A false
/// filename hint falls back to the generic file chip without reaching rodio.
fn play_ready_audio(state: &mut AppState, id: crate::files::AttachmentId) {
let Some(AttachmentState::Ready(data)) = state.attachment_data.get(&id) else {
return;
};
if crate::files::is_probably_audio(data) {
state.invalid_audio.remove(&id);
state.clip_player.play(id, data.clone());
} else {
state.invalid_audio.insert(id);
state.status_message = "This attachment is not valid supported audio.".to_string();
}
}
/// Write a ready attachment's bytes to a user-chosen location via a native save
/// dialog. The default filename comes from the (already-sanitized) descriptor.
/// Build a Task that opens the native save dialog off the UI thread and writes
/// the (already-fetched) attachment bytes to the chosen path. No-op task if the
/// bytes aren't ready.
///
/// MUST be async (`rfd::AsyncFileDialog`): the blocking `rfd::FileDialog` spins
/// its own GTK loop, and invoking it from inside iced's running event loop with
/// the Linux `xdg-desktop-portal`/GTK backend wedges the dialog — Save/Cancel
/// stop responding. The file *picker* paths already use the async variant; this
/// is the one save path that must match.
fn save_attachment_task(state: &AppState, id: crate::files::AttachmentId) -> Task<AppMessage> {
let Some(AttachmentState::Ready(data)) = state.attachment_data.get(&id) else {
return Task::none();
};
let data = data.clone();
let default_name = state
.chat_messages
@@ -1904,16 +2013,23 @@ fn save_attachment_to_disk(state: &mut AppState, id: crate::files::AttachmentId)
.map(|a| a.name.clone())
})
.unwrap_or_else(|| "download".to_string());
if let Some(path) = rfd::FileDialog::new()
.set_file_name(default_name)
.set_title("Save attachment")
.save_file()
{
match std::fs::write(&path, &data) {
Ok(()) => state.status_message = format!("Saved {}", path.display()),
Err(e) => state.status_message = format!("Save failed: {e}"),
}
}
Task::perform(
async move {
let handle = rfd::AsyncFileDialog::new()
.set_file_name(default_name)
.set_title("Save attachment")
.save_file()
.await;
match handle {
Some(h) => match std::fs::write(h.path(), &data) {
Ok(()) => Some(format!("Saved {}", h.path().display())),
Err(e) => Some(format!("Save failed: {e}")),
},
None => None,
}
},
AppMessage::AttachmentSaved,
)
}
fn horizontal_space() -> iced::widget::Space {
@@ -3833,6 +3949,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.color(color_subtext),
);
} else {
let clip_status = status_snapshot(&state.clip_status);
for m in &state.chat_messages {
let name_color = if m.mine { color_green } else { color_lavender };
// Split the (already-sanitized) message into text + URL spans so
@@ -3899,6 +4016,87 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.color(color_subtext)
.into(),
}
} else if crate::files::looks_like_audio_name(&att.name)
&& !state.invalid_audio.contains(&att.id)
{
let active = clip_status.playing_id == Some(att.id);
let loading = state.pending_plays.contains(&att.id)
&& !matches!(data, Some(AttachmentState::Ready(_)));
let position = if active {
clip_status.position
} else {
std::time::Duration::ZERO
};
let total = active.then_some(clip_status.total).flatten();
let play_button = if loading {
button(text("Loading…").size(12))
} else if active && clip_status.paused {
button(text("Play").size(12)).on_press(AppMessage::ResumeAudio)
} else if active {
button(text("Pause").size(12)).on_press(AppMessage::PauseAudio)
} else {
button(text("Play").size(12))
.on_press(AppMessage::PlayAudio(att.id))
}
.style(b_style(
color_blue,
color_lavender,
color_crust,
6.0,
))
.padding(6);
let elapsed = format_clip_time(position);
let duration = total
.map(format_clip_time)
.unwrap_or_else(|| "--:--".to_string());
column![
row![
text(format!(
"{} ({})",
att.name,
crate::files::human_size(att.size)
))
.size(12)
.color(color_text),
button(text(if matches!(data, Some(AttachmentState::Ready(_))) {
"Save"
} else {
"Download"
})
.size(12))
.on_press(AppMessage::SaveAttachment(att.id))
.style(b_style(
color_surface,
color_overlay,
color_text,
6.0,
))
.padding(6),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center),
row![
play_button,
slider(
0.0..=1.0,
if active {
clip_progress(position, total)
} else {
0.0
},
move |fraction| AppMessage::SeekAudio(att.id, fraction),
)
.step(0.001)
.width(iced::Length::Fixed(180.0)),
text(format!("{elapsed} / {duration}"))
.size(11)
.color(color_subtext),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center),
]
.spacing(4)
.into()
} else {
let ready =
matches!(data, Some(AttachmentState::Ready(_)));
+306
View File
@@ -0,0 +1,306 @@
//! Independent playback engine for inline chat audio attachments.
//!
//! The rodio device sink stays on a dedicated OS thread and never enters iced
//! state or the call-audio pipeline. The GUI sends small commands and reads a
//! shared status snapshot at its redraw cadence.
use crate::files::AttachmentId;
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, decoder::DecoderError};
use std::io::Cursor;
use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
/// State published by the playback thread for the GUI.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClipStatus {
pub playing_id: Option<AttachmentId>,
pub position: Duration,
pub total: Option<Duration>,
pub paused: bool,
/// Set when output initialization or decoding rejects the requested clip.
/// The app consumes this as a signal to fall back to the normal file chip.
pub failure: Option<ClipFailure>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipFailure {
pub id: AttachmentId,
pub error: String,
/// Decoder rejection means the filename hint should fall back to a file
/// chip. Output-device failures remain retryable as audio.
pub invalid_audio: bool,
}
pub type SharedClipStatus = Arc<Mutex<ClipStatus>>;
#[derive(Debug)]
enum ClipCommand {
Play(AttachmentId, Vec<u8>),
Pause,
Resume,
Seek(Duration),
Stop,
}
/// Cheap, `Send` command handle for the dedicated playback thread.
pub struct ClipPlayer {
command_tx: mpsc::Sender<ClipCommand>,
status: SharedClipStatus,
}
impl ClipPlayer {
/// Start the playback worker. The system output device is opened lazily on
/// first Play, so merely launching PeerSpeak never claims another stream.
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))
.expect("failed to spawn clip playback thread");
(
Self {
command_tx,
status: Arc::clone(&status),
},
status,
)
}
pub fn play(&self, id: AttachmentId, bytes: Vec<u8>) {
update_status(&self.status, |status| {
status.playing_id = Some(id);
status.position = Duration::ZERO;
status.total = None;
status.paused = false;
status.failure = None;
});
let _ = self.command_tx.send(ClipCommand::Play(id, bytes));
}
pub fn pause(&self) {
let _ = self.command_tx.send(ClipCommand::Pause);
}
pub fn resume(&self) {
let _ = self.command_tx.send(ClipCommand::Resume);
}
pub fn seek(&self, position: Duration) {
let _ = self.command_tx.send(ClipCommand::Seek(position));
}
pub fn stop(&self) {
let _ = self.command_tx.send(ClipCommand::Stop);
}
}
fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipStatus) {
let mut output: Option<MixerDeviceSink> = None;
let mut player: Option<Player> = None;
loop {
match command_rx.recv_timeout(Duration::from_millis(100)) {
Ok(ClipCommand::Play(id, bytes)) => {
// In-memory readers do not expose file metadata to rodio. Pass
// the known attachment length explicitly so formats without a
// duration in their headers (notably MP3 and Vorbis) can derive
// a total duration and support reliable seeking.
let source = match decode_clip(bytes) {
Ok(source) => source,
Err(error) => {
fail(
&status,
id,
format!("unsupported or invalid audio: {error}"),
true,
);
continue;
}
};
let total = source.total_duration();
if output.is_none() {
match DeviceSinkBuilder::open_default_sink() {
Ok(sink) => {
player = Some(Player::connect_new(sink.mixer()));
output = Some(sink);
}
Err(error) => {
fail(
&status,
id,
format!("audio output unavailable: {error}"),
false,
);
continue;
}
}
}
if let Some(player) = player.as_ref() {
player.clear();
player.append(source);
player.play();
update_status(&status, |s| {
s.playing_id = Some(id);
s.position = Duration::ZERO;
s.total = total;
s.paused = false;
s.failure = None;
});
}
}
Ok(ClipCommand::Pause) => {
if let Some(player) = player.as_ref() {
player.pause();
update_status(&status, |s| s.paused = true);
}
}
Ok(ClipCommand::Resume) => {
if let Some(player) = player.as_ref() {
player.play();
update_status(&status, |s| s.paused = false);
}
}
Ok(ClipCommand::Seek(position)) => {
if let Some(player) = player.as_ref()
&& player.try_seek(position).is_ok()
{
update_status(&status, |s| s.position = position);
}
}
Ok(ClipCommand::Stop) => {
if let Some(player) = player.as_ref() {
player.clear();
}
reset(&status);
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if let Some(player) = player.as_ref() {
let (active, failed) = status
.lock()
.map(|s| (s.playing_id.is_some(), s.failure.is_some()))
.unwrap_or_default();
if active && !failed && player.empty() {
reset(&status);
} else if active && !failed {
update_status(&status, |s| {
s.position = player.get_pos();
s.paused = player.is_paused();
});
}
}
}
}
fn decode_clip(bytes: Vec<u8>) -> Result<Decoder<Cursor<Vec<u8>>>, DecoderError> {
let byte_len = bytes.len() as u64;
Decoder::builder()
.with_data(Cursor::new(bytes))
.with_byte_len(byte_len)
.build()
}
fn fail(status: &SharedClipStatus, id: AttachmentId, error: String, invalid_audio: bool) {
crate::log_msg(&format!("Inline audio playback failed: {error}"));
update_status(status, |s| {
// Keep the id active until the GUI observes the failure on its next
// tick. This guarantees the active-only timer cannot disappear in the
// small window between sending Play and decoder/output failure.
s.playing_id = Some(id);
s.position = Duration::ZERO;
s.total = None;
s.paused = false;
s.failure = Some(ClipFailure {
id,
error,
invalid_audio,
});
});
}
fn reset(status: &SharedClipStatus) {
update_status(status, |s| *s = ClipStatus::default());
}
fn update_status(status: &SharedClipStatus, update: impl FnOnce(&mut ClipStatus)) {
if let Ok(mut status) = status.lock() {
update(&mut status);
}
}
pub fn status_snapshot(status: &SharedClipStatus) -> ClipStatus {
status.lock().map(|s| s.clone()).unwrap_or_default()
}
/// Format clip time as `mm:ss` (hours are folded into minutes).
pub fn format_time(duration: Duration) -> String {
let seconds = duration.as_secs();
format!("{}:{:02}", seconds / 60, seconds % 60)
}
/// Playback progress in `0.0..=1.0`; unknown and zero durations report zero.
pub fn progress(position: Duration, total: Option<Duration>) -> f32 {
let Some(total) = total.filter(|duration| !duration.is_zero()) else {
return 0.0;
};
(position.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0) as f32
}
/// Convert a slider fraction into a clamped position within a clip.
pub fn seek_target(fraction: f32, total: Duration) -> Duration {
total.mul_f64(f64::from(fraction.clamp(0.0, 1.0)))
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
#[test]
fn in_memory_mp3_reports_duration() {
// One headerless constant-bitrate MP3 frame repeated to model files
// that do not carry an Xing/VBR duration header.
let frame = base64::engine::general_purpose::STANDARD
.decode("//sQxAAABIQVWVRggDCqCKiDNlAAAAGgS4BgAmTT2AQAABCxOD5d7gQOfqBAEHS4Ph/EAIRI7//0A0KBNpABgMRIDCSI04PcIFdF0PJKFgzlUf5eAoF8BRIPfh4FTvUDQl+dUi5pc0w=")
.expect("valid test fixture");
let bytes = frame.repeat(20);
let decoder = decode_clip(bytes).expect("CBR MP3 should decode");
assert!(decoder.total_duration().is_some());
}
#[test]
fn formats_clip_time() {
assert_eq!(format_time(Duration::ZERO), "0:00");
assert_eq!(format_time(Duration::from_secs(65)), "1:05");
assert_eq!(format_time(Duration::from_secs(3_661)), "61:01");
}
#[test]
fn progress_handles_unknown_zero_and_clamps() {
assert_eq!(progress(Duration::from_secs(1), None), 0.0);
assert_eq!(progress(Duration::from_secs(1), Some(Duration::ZERO)), 0.0);
assert_eq!(
progress(Duration::from_secs(5), Some(Duration::from_secs(10))),
0.5
);
assert_eq!(
progress(Duration::from_secs(20), Some(Duration::from_secs(10))),
1.0
);
}
#[test]
fn seek_target_clamps_fraction() {
let total = Duration::from_secs(100);
assert_eq!(seek_target(0.25, total), Duration::from_secs(25));
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
assert_eq!(seek_target(2.0, total), total);
}
}
+1
View File
@@ -56,6 +56,7 @@ pub trait AudioBackend: Send + Sync {
fn stop(&self) -> Result<(), AudioError>;
}
pub mod clip_player;
pub mod eq;
pub mod gate;
pub mod limiter;
+73
View File
@@ -125,6 +125,29 @@ pub fn is_probably_image(bytes: &[u8]) -> bool {
png || jpeg || gif || bmp || webp
}
/// Sniff the leading bytes for an audio container supported by the inline clip
/// player. Audio remains [`AttachmentKind::File`] on the wire; this receiver-side
/// check confirms that a filename-based player hint actually contains WAV, MP3,
/// Ogg Vorbis, or FLAC data before playback is attempted.
pub fn is_probably_audio(bytes: &[u8]) -> bool {
let flac = bytes.starts_with(b"fLaC");
let ogg = bytes.starts_with(b"OggS");
let wav = bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE";
let mp3_id3 = bytes.starts_with(b"ID3");
let mp3_frame = bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] & 0xE0 == 0xE0;
flac || ogg || wav || mp3_id3 || mp3_frame
}
/// Whether a sanitized attachment name has an extension supported by the
/// inline audio player. This is only a pre-fetch presentation hint; fetched
/// bytes are confirmed with [`is_probably_audio`] before being decoded.
pub fn looks_like_audio_name(name: &str) -> bool {
let Some((_, extension)) = name.rsplit_once('.') else {
return false;
};
matches!(extension.to_ascii_lowercase().as_str(), "wav" | "mp3" | "ogg" | "oga" | "flac")
}
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
/// sniffs as an image container, else [`AttachmentKind::File`].
pub fn classify(bytes: &[u8]) -> AttachmentKind {
@@ -242,9 +265,59 @@ mod tests {
assert!(!is_probably_image(b""));
}
#[test]
fn audio_sniffing_recognizes_supported_containers() {
assert!(is_probably_audio(b"fLaC\0\0\0\x22"));
assert!(is_probably_audio(b"OggS\0\x02"));
let mut wav = b"RIFF".to_vec();
wav.extend_from_slice(&[0, 0, 0, 0]);
wav.extend_from_slice(b"WAVE");
assert!(is_probably_audio(&wav));
assert!(is_probably_audio(b"ID3\x04\0\0"));
assert!(is_probably_audio(&[0xFF, 0xFB, 0x90, 0x64]));
}
#[test]
fn audio_sniffing_disambiguates_wav_from_webp() {
let mut wav = b"RIFF".to_vec();
wav.extend_from_slice(&[0, 0, 0, 0]);
wav.extend_from_slice(b"WAVE");
assert!(is_probably_audio(&wav));
assert!(!is_probably_image(&wav));
let mut webp = b"RIFF".to_vec();
webp.extend_from_slice(&[0, 0, 0, 0]);
webp.extend_from_slice(b"WEBP");
assert!(is_probably_image(&webp));
assert!(!is_probably_audio(&webp));
}
#[test]
fn audio_sniffing_rejects_non_audio() {
assert!(!is_probably_audio(b"%PDF-1.7"));
assert!(!is_probably_audio(&[0x89, b'P', b'N', b'G']));
assert!(!is_probably_audio(&[]));
assert!(!is_probably_audio(&[0xFF]));
}
#[test]
fn audio_name_detection_is_case_insensitive() {
for name in ["clip.wav", "clip.mp3", "clip.ogg", "clip.oga", "clip.flac"] {
assert!(looks_like_audio_name(name), "{name}");
}
assert!(looks_like_audio_name("VOICE.MP3"));
assert!(looks_like_audio_name("mix.FlAc"));
assert!(!looks_like_audio_name("recording"));
assert!(!looks_like_audio_name("notes.pdf"));
assert!(!looks_like_audio_name("photo.webp"));
}
#[test]
fn classify_maps_sniff_to_kind() {
assert_eq!(classify(&[0xFF, 0xD8, 0xFF]), AttachmentKind::Image);
assert_eq!(classify(b"fLaC\0\0\0\x22"), AttachmentKind::File);
assert_eq!(classify(b"plain text"), AttachmentKind::File);
}
+9
View File
@@ -489,6 +489,15 @@ impl iroh::protocol::ProtocolHandler for FileRouter {
// Finish either way: an unknown id closes with an empty body, which
// the fetcher reads as a zero-length result and treats as "gone".
let _ = send.finish();
// CRITICAL: `finish()` only marks the stream's EOF — it does NOT wait
// for the written bytes to be delivered and acknowledged. If we return
// here the `connection` drops, and its CONNECTION_CLOSE can race ahead
// of the still-in-flight stream data, so the fetcher's read aborts with
// "connection lost". Wait for the fetcher to receive everything and
// close the connection itself (it drops `conn` right after read_to_end);
// that close is our signal the transfer landed. Bounded so a fetcher
// that vanishes can't pin this task forever.
let _ = tokio::time::timeout(FILE_FETCH_TIMEOUT, connection.closed()).await;
Ok(())
}
}
+141
View File
@@ -0,0 +1,141 @@
//! End-to-end loopback test for the chat file-transfer plane (`FILES_ALPN`).
//!
//! Spins up two real iroh endpoints on localhost (relay disabled, addresses
//! exchanged directly), registers the production [`FileRouter`] on each, serves a
//! multi-megabyte blob on one side, and fetches it from the other through the
//! real `serve_attachment`/`fetch_attachment` path.
//!
//! This is the regression guard for the "file fetch: read failed: connection
//! lost" bug: the serve handler used to return (and drop the connection) the
//! instant it called `finish()`, so the CONNECTION_CLOSE raced ahead of the
//! still-in-flight stream data and the fetcher's `read_to_end` aborted. A blob
//! large enough to span many packets makes that race deterministic — the fix
//! (waiting on `connection.closed()` before returning) keeps the link up until
//! the fetcher has the bytes.
use std::sync::Arc;
use std::time::Duration;
use iroh::address_lookup::memory::MemoryLookup;
use iroh::endpoint::presets;
use iroh::protocol::Router;
use iroh::{Endpoint, RelayMode};
use peerspeak::files::{ChatAttachment, AttachmentKind};
use peerspeak::network::NetworkTransport;
use peerspeak::network::iroh_impl::{FileRouter, IrohTransport};
use peerspeak::protocol::FILES_ALPN;
struct Node {
endpoint: Endpoint,
transport: Arc<IrohTransport>,
_router: Router,
lookup: MemoryLookup,
}
async fn spawn_node() -> Node {
let lookup = MemoryLookup::new();
let endpoint = Endpoint::builder(presets::Minimal)
.secret_key(iroh::SecretKey::generate())
.relay_mode(RelayMode::Disabled)
.address_lookup(lookup.clone())
.bind()
.await
.expect("bind endpoint");
let transport = Arc::new(IrohTransport::new(endpoint.clone()));
// Mirror production: a persistent FileRouter bound to this session's transport
// is what the router accepts inbound file fetches on.
let file_router = FileRouter::new();
file_router.bind(&transport);
let router = Router::builder(endpoint.clone())
.accept(FILES_ALPN, file_router)
.spawn();
Node { endpoint, transport, _router: router, lookup }
}
/// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a
/// premature connection close on the serve side reliably corrupts/aborts the read.
fn big_blob() -> Vec<u8> {
(0..(2 * 1024 * 1024u32))
.map(|i| (i.wrapping_mul(2654435761) >> 13) as u8)
.collect()
}
#[tokio::test]
async fn loopback_attachment_round_trips_intact() {
let server = spawn_node().await;
let client = spawn_node().await;
// Seed each side with the other's full address so direct dialing works.
server.lookup.add_endpoint_info(client.endpoint.addr());
client.lookup.add_endpoint_info(server.endpoint.addr());
let server_id = server.endpoint.id();
let client_id = client.endpoint.id();
// The serve handler gates on room membership (the audio admission roster), so
// the server must admit the client before it will answer the fetch.
server.transport.admit_audio_sender(client_id);
client.transport.admit_audio_sender(server_id);
// The fetcher dials the retained full address; seed it so fetch_attachment
// doesn't have to fall back to a bare-id lookup.
client.transport.connect_peer(server.endpoint.addr()).await;
let blob = big_blob();
let id = [42u8; 32];
server.transport.serve_attachment(id, Arc::new(blob.clone()));
let att = ChatAttachment {
name: "exterior-landscape.jpg".to_string(),
size: blob.len() as u64,
kind: AttachmentKind::Image,
id,
};
let fetched = tokio::time::timeout(
Duration::from_secs(30),
client.transport.fetch_attachment(server_id, &att),
)
.await
.expect("fetch did not time out")
.expect("fetch succeeded");
assert_eq!(fetched.len(), blob.len(), "fetched the full blob");
assert_eq!(fetched, blob, "fetched bytes match served bytes exactly");
}
#[tokio::test]
async fn loopback_unknown_id_reports_gone() {
let server = spawn_node().await;
let client = spawn_node().await;
server.lookup.add_endpoint_info(client.endpoint.addr());
client.lookup.add_endpoint_info(server.endpoint.addr());
let server_id = server.endpoint.id();
let client_id = client.endpoint.id();
server.transport.admit_audio_sender(client_id);
client.transport.admit_audio_sender(server_id);
client.transport.connect_peer(server.endpoint.addr()).await;
// Never served — the handler closes with an empty body and the fetcher must
// surface that as an error, not hang or return empty bytes.
let att = ChatAttachment {
name: "missing.bin".to_string(),
size: 4096,
kind: AttachmentKind::File,
id: [7u8; 32],
};
let result = tokio::time::timeout(
Duration::from_secs(30),
client.transport.fetch_attachment(server_id, &att),
)
.await
.expect("fetch did not time out");
assert!(result.is_err(), "unknown id should error, got {result:?}");
}