Bump protocols. Added proto status

This commit is contained in:
Ultradesu
2026-07-28 22:18:29 +01:00
parent 34153cca9d
commit 89c78dcadc
12 changed files with 888 additions and 21 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ use crate::library::models::{ArtistRef, TrackItem};
pub const SYNC_ALPN: &[u8] = b"furumi/sync/2";
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const PROTOCOL_VERSION: u16 = 2;
pub const PROTOCOL_VERSION: u16 = 2;
const INVITE_TTL_MS: i64 = 10 * 60 * 1000;
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
+2
View File
@@ -18,6 +18,8 @@ use crate::library::Library;
/// ALPN of the audio streaming protocol (shared with furumi-fd).
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
/// Version of the audio transfer stream protocol.
pub const AUDIO_PROTOCOL_VERSION: u16 = 1;
/// Maximum size of a JSON protocol line (request or response header).
const MAX_PROTOCOL_LINE: usize = 4096;
+190
View File
@@ -0,0 +1,190 @@
//! Informational publication and observation of protocol versions.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
use anyhow::{Context, Result};
pub use music_dht::capabilities::CAPABILITIES_ALPN;
use music_dht::capabilities::{
CAPABILITIES_PROTOCOL_VERSION, CapabilityManifest, CapabilityMessage, read_message,
write_message,
};
use music_dht::{ByteStream, EndpointId, MusicDhtService, StreamAcceptor};
const PROBE_INTERVAL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProtocolVersions {
pub local: BTreeMap<String, u16>,
pub observed: BTreeMap<String, u16>,
pub observed_peers: usize,
pub newer: Vec<NewerProtocol>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewerProtocol {
pub id: String,
pub local: u16,
pub observed: u16,
}
impl ProtocolVersions {
pub fn snapshot(observed: &ObservedVersions) -> Self {
let local = local_manifest().protocols;
let observed_versions = lock(&observed.versions).clone();
let newer = observed_versions
.iter()
.filter_map(|(id, remote)| {
let local_version = local.get(id)?;
(*remote > *local_version).then(|| NewerProtocol {
id: id.clone(),
local: *local_version,
observed: *remote,
})
})
.collect();
Self {
local,
observed: observed_versions,
observed_peers: lock(&observed.peers).len(),
newer,
}
}
}
#[derive(Default)]
pub struct ObservedVersions {
versions: Mutex<BTreeMap<String, u16>>,
peers: Mutex<BTreeMap<String, String>>,
}
fn local_manifest() -> CapabilityManifest {
CapabilityManifest::frid("furumi", env!("CARGO_PKG_VERSION"))
.with_protocol("audio", super::audio::AUDIO_PROTOCOL_VERSION)
}
pub async fn serve(mut acceptor: StreamAcceptor) {
while let Some(stream) = acceptor.accept().await {
tokio::spawn(async move {
if let Err(error) = serve_one(stream).await {
tracing::debug!("capability stream failed: {error:#}");
}
});
}
}
async fn serve_one(mut stream: ByteStream) -> Result<()> {
let response = match read_message(&mut stream).await? {
CapabilityMessage::Get {
version: CAPABILITIES_PROTOCOL_VERSION,
} => CapabilityMessage::Manifest {
manifest: local_manifest(),
},
CapabilityMessage::Get { version } => CapabilityMessage::Error {
message: format!("unsupported capability protocol {version}"),
},
_ => CapabilityMessage::Error {
message: "expected capability request".to_string(),
},
};
write_message(&mut stream, &response).await?;
stream.send.finish()?;
let _ = tokio::time::timeout(Duration::from_secs(2), stream.send.stopped()).await;
Ok(())
}
pub async fn probe_loop(service: Arc<MusicDhtService>, observed: Arc<ObservedVersions>) {
let mut interval = tokio::time::interval(PROBE_INTERVAL);
loop {
interval.tick().await;
let peers = service
.connected_peers()
.into_iter()
.chain(
service
.known_peers()
.into_iter()
.map(|contact| contact.peer_id),
)
.collect::<std::collections::BTreeSet<_>>();
for peer in peers {
if let Err(error) = probe_peer(&service, peer, &observed).await {
tracing::trace!(%peer, "peer capability probe unavailable: {error:#}");
}
}
}
}
async fn probe_peer(
service: &MusicDhtService,
peer: EndpointId,
observed: &ObservedVersions,
) -> Result<()> {
let mut stream = service.open_stream(peer, CAPABILITIES_ALPN).await?;
write_message(
&mut stream,
&CapabilityMessage::Get {
version: CAPABILITIES_PROTOCOL_VERSION,
},
)
.await?;
stream.send.finish()?;
let response = tokio::time::timeout(Duration::from_secs(5), read_message(&mut stream))
.await
.context("capability request timed out")??;
let CapabilityMessage::Manifest { manifest } = response else {
anyhow::bail!("peer did not return a capability manifest");
};
manifest.validate()?;
{
let mut versions = lock(&observed.versions);
for (id, version) in manifest.protocols {
versions
.entry(id)
.and_modify(|current| *current = (*current).max(version))
.or_insert(version);
}
}
lock(&observed.peers).insert(peer.to_string(), manifest.application_version);
Ok(())
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_manifest_lists_every_player_protocol() {
let manifest = local_manifest();
for id in [
"federation_net",
"ticket",
"rendezvous",
"music_dht",
"catalog",
"audio",
"device_sync",
"jam",
] {
assert!(manifest.protocols.contains_key(id), "missing {id}");
}
manifest.validate().unwrap();
}
#[test]
fn snapshot_reports_only_strictly_newer_versions() {
let observed = ObservedVersions::default();
lock(&observed.versions).insert("music_dht".to_string(), 99);
lock(&observed.versions).insert("jam".to_string(), crate::jam::PROTOCOL_VERSION);
let snapshot = ProtocolVersions::snapshot(&observed);
assert_eq!(snapshot.newer.len(), 1);
assert_eq!(snapshot.newer[0].id, "music_dht");
}
}
+18
View File
@@ -13,6 +13,7 @@
//! the network too).
mod audio;
mod capabilities;
pub mod catalog;
use std::collections::{HashMap, VecDeque};
@@ -36,6 +37,7 @@ use crate::library::NetworkArtistPreview;
use crate::library::models::{ArtistRef, TrackItem};
pub use audio::{AUDIO_ALPN, DownloadProgress, StreamingStart, TrackMetadata};
pub use capabilities::ProtocolVersions;
pub use catalog::{CATALOG_ALPN, FedAppearsOn, FedArtistCard, FedCardTrack, FedRelease};
/// How often the published library is re-synchronized with the local index.
@@ -386,6 +388,7 @@ pub struct FedStatus {
pub last_sync: Option<String>,
pub last_error: Option<String>,
pub transport: TransportStatsSnapshot,
pub protocols: ProtocolVersions,
}
/// Outcome of preparing a federated track for playback.
@@ -426,6 +429,7 @@ pub struct Federation {
last_sync: std::sync::Mutex<Option<String>>,
last_error: std::sync::Mutex<Option<String>>,
transport_stats: Arc<TransportStats>,
observed_protocols: Arc<capabilities::ObservedVersions>,
}
#[derive(Debug, Clone)]
@@ -554,6 +558,7 @@ impl Federation {
last_sync: std::sync::Mutex::new(None),
last_error: std::sync::Mutex::new(initial_error),
transport_stats: Arc::new(TransportStats::default()),
observed_protocols: Arc::new(capabilities::ObservedVersions::default()),
})
}
@@ -656,6 +661,8 @@ impl Federation {
.stream_protocol(crate::devices::SYNC_ALPN)
// Capability-scoped shared playback control.
.stream_protocol(crate::jam::JAM_ALPN)
// Informational application/protocol versions.
.schema_independent_stream_protocol(capabilities::CAPABILITIES_ALPN)
.build()
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
let (service, mut events) = MusicDhtService::start(config)
@@ -728,6 +735,14 @@ impl Federation {
Arc::clone(&self.jam),
Arc::clone(&service),
));
let capabilities_acceptor = service
.stream_acceptor(capabilities::CAPABILITIES_ALPN)
.map_err(|err| anyhow::anyhow!("failed to take capabilities acceptor: {err}"))?;
let capabilities_serve_task = tokio::spawn(capabilities::serve(capabilities_acceptor));
let capabilities_probe_task = tokio::spawn(capabilities::probe_loop(
Arc::clone(&service),
Arc::clone(&self.observed_protocols),
));
*guard = Some(Running {
service,
@@ -742,6 +757,8 @@ impl Federation {
device_tick_task,
jam_serve_task,
jam_poll_task,
capabilities_serve_task,
capabilities_probe_task,
],
});
self.set_error(None);
@@ -880,6 +897,7 @@ impl Federation {
network: settings.network_id,
last_sync: lock(&self.last_sync).clone(),
last_error: lock(&self.last_error).clone(),
protocols: ProtocolVersions::snapshot(&self.observed_protocols),
..FedStatus::default()
};
if let Some(running) = guard.as_ref() {
+1 -5
View File
@@ -13,7 +13,7 @@ use crate::app::event::AppEvent;
use crate::devices::{PlaybackCommand, PlaybackSnapshot};
pub const JAM_ALPN: &[u8] = b"furumi/jam/1";
const PROTOCOL_VERSION: u16 = 1;
pub const PROTOCOL_VERSION: u16 = 1;
const MAX_LINE: usize = 8 * 1024 * 1024;
const MAX_COMMANDS: usize = 128;
const PARTICIPANT_TTL_MS: i64 = 30 * 60 * 1_000;
@@ -29,7 +29,6 @@ pub enum JamRole {
#[derive(Debug, Clone)]
pub struct JamStatus {
pub role: JamRole,
pub jam_id: Option<String>,
pub host_name: Option<String>,
pub invite: Option<String>,
pub participants: Vec<JamParticipant>,
@@ -41,7 +40,6 @@ impl Default for JamStatus {
fn default() -> Self {
Self {
role: JamRole::None,
jam_id: None,
host_name: None,
invite: None,
participants: Vec::new(),
@@ -207,7 +205,6 @@ impl JamManager {
if let Some(joined) = &state.joined {
return JamStatus {
role: JamRole::Participant,
jam_id: Some(joined.invite.jam_id.clone()),
host_name: Some(joined.invite.host_name.clone()),
invite: None,
participants: joined.participants.clone(),
@@ -218,7 +215,6 @@ impl JamManager {
if let Some(invite) = &state.host.invite {
return JamStatus {
role: JamRole::Host,
jam_id: Some(invite.jam_id.clone()),
host_name: Some(invite.host_name.clone()),
invite: state.host.invite_uri.clone(),
participants: state.host.participants.values().cloned().collect(),
+164 -3
View File
@@ -2,6 +2,7 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
@@ -338,6 +339,20 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
);
}
fn protocol_label(id: &str) -> &str {
match id {
"federation_net" => "Federation transport",
"ticket" => "Peer ticket",
"rendezvous" => "Rendezvous",
"music_dht" => "Music DHT",
"catalog" => "Catalog",
"audio" => "Audio transfer",
"device_sync" => "Device sync",
"jam" => "Jam",
other => other,
}
}
fn draw_section(frame: &mut Frame, area: Rect, state: &AppState, y: &mut u16, title: &'static str) {
if *y >= area.y + area.height {
return;
@@ -485,11 +500,16 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
return;
}
if area.width >= 60 && area.height >= 15 {
let [top_area, _, bottom_area] = Layout::vertical([
if area.width >= 60 && area.height >= 20 {
let protocols_height =
protocol_card_height(state, area.width.saturating_sub(2), area.height);
let [top_area, _, bottom_area, _, protocols_area, _] = Layout::vertical([
Constraint::Length(7),
Constraint::Length(1),
Constraint::Length(7),
Constraint::Length(1),
Constraint::Length(protocols_height),
Constraint::Min(0),
])
.areas(area);
let [status_area, _, local_area] = Layout::horizontal([
@@ -532,10 +552,17 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
" Connected Devices ",
device_summary_lines(state),
);
draw_summary_card(
frame,
protocols_area,
state,
" Protocol Versions ",
protocol_summary_lines(state, protocols_area.width.saturating_sub(2)),
);
return;
}
if area.height < 31 {
if area.height < 39 {
frame.render_widget(
Paragraph::new(compact_status_lines(state))
.wrap(ratatui::widgets::Wrap { trim: false }),
@@ -553,6 +580,8 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
_,
local_area,
_,
protocols_area,
_,
] = Layout::vertical([
Constraint::Length(7),
Constraint::Length(1),
@@ -561,6 +590,12 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
Constraint::Length(7),
Constraint::Length(1),
Constraint::Length(7),
Constraint::Length(1),
Constraint::Length(protocol_card_height(
state,
area.width.saturating_sub(2),
area.height,
)),
Constraint::Min(0),
])
.areas(area);
@@ -593,6 +628,13 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
" Local Data ",
local_data_summary_lines(state),
);
draw_summary_card(
frame,
protocols_area,
state,
" Protocol Versions ",
protocol_summary_lines(state, protocols_area.width.saturating_sub(2)),
);
}
fn compact_status_lines(state: &AppState) -> Vec<Line<'static>> {
@@ -608,6 +650,9 @@ fn compact_status_lines(state: &AppState) -> Vec<Line<'static>> {
lines.push(Line::default());
lines.push(Line::styled("Connected Devices", theme::header_for(state)));
lines.extend(device_summary_lines(state).into_iter().take(2));
lines.push(Line::default());
lines.push(Line::styled("Protocol Versions", theme::header_for(state)));
lines.extend(protocol_summary_lines(state, 0).into_iter().take(3));
lines
}
@@ -637,6 +682,122 @@ fn summary_line(label: &'static str, value: String) -> Line<'static> {
])
}
fn protocol_summary_line(
label: &str,
value: String,
style: Style,
label_width: usize,
) -> Line<'static> {
Line::from(vec![
Span::styled(
format!("{:<label_width$}", protocol_label(label)),
theme::dim(),
),
Span::styled(value, style),
])
}
fn protocol_summary_lines(state: &AppState, width: u16) -> Vec<Line<'static>> {
let Some(status) = state.federation.status.as_ref() else {
return vec![protocol_summary_line(
"status",
"[UNKNOWN]".to_string(),
theme::dim(),
22,
)];
};
let protocols = &status.protocols;
let newer = !protocols.newer.is_empty();
let badge = if newer {
"[NEWER VERSION SEEN]"
} else if status.running && protocols.observed_peers == 0 {
"[CURRENT · waiting for peers]"
} else {
"[CURRENT]"
};
let badge_style = Style::new()
.fg(if newer { Color::LightRed } else { Color::Green })
.add_modifier(Modifier::BOLD);
let mut lines = vec![protocol_summary_line(
"status",
badge.to_string(),
badge_style,
22,
)];
let mut entries = Vec::new();
for (id, local) in &protocols.local {
let observed = protocols.observed.get(id).copied();
let value = match observed {
Some(remote) if remote > *local => format!("local {local} · network {remote}"),
Some(remote) => format!("{local} · seen {remote}"),
None => local.to_string(),
};
let style = if observed.is_some_and(|remote| remote > *local) {
Style::new()
.fg(Color::LightRed)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
entries.push((id.as_str(), value, style));
}
if width >= 58 {
let cell_width = width as usize / 2;
let label_width = 22.min(cell_width.saturating_sub(3));
let value_width = cell_width.saturating_sub(label_width + 2);
for pair in entries.chunks(2) {
let mut spans =
protocol_cell_spans(pair[0].0, &pair[0].1, pair[0].2, label_width, value_width);
if let Some(second) = pair.get(1) {
spans.push(Span::styled(" ", theme::dim()));
spans.extend(protocol_cell_spans(
second.0,
&second.1,
second.2,
label_width,
value_width,
));
}
lines.push(Line::from(spans));
}
} else {
lines.extend(
entries
.into_iter()
.map(|(id, value, style)| protocol_summary_line(id, value, style, 22)),
);
}
if newer {
lines.push(Line::styled(
"A newer protocol was observed; update Furumi for compatibility.",
Style::new().fg(Color::LightRed),
));
}
lines
}
fn protocol_card_height(state: &AppState, width: u16, available: u16) -> u16 {
let content = protocol_summary_lines(state, width).len() as u16;
content.saturating_add(2).min(available)
}
fn protocol_cell_spans(
label: &str,
value: &str,
style: Style,
label_width: usize,
value_width: usize,
) -> Vec<Span<'static>> {
let value = value.chars().take(value_width).collect::<String>();
vec![
Span::styled(
format!("{:<label_width$}", protocol_label(label)),
theme::dim(),
),
Span::styled(format!("{value:<value_width$}"), style),
]
}
fn node_summary_lines(state: &AppState) -> Vec<Line<'static>> {
match &state.federation.status {
None => vec![