Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f9539ffd7 | ||
|
|
d854a4d4e0 | ||
|
|
6175423b0c | ||
|
|
93dc8f02fb | ||
|
|
89c78dcadc |
Generated
+12
-12
@@ -434,8 +434,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "block"
|
name = "block"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
@@ -1211,13 +1209,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1454,8 +1452,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "federation-net"
|
name = "federation-net"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git?rev=8de7d1292708fa0b225e5a4a9d5ab4f0676202d3#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15a8707baeccb46b5935138f9cb3df3c988c0730b807a2634d901f26b39250d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -1566,7 +1565,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumi_tui"
|
name = "furumi_tui"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -2988,8 +2987,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "music-dht"
|
name = "music-dht"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git?rev=8de7d1292708fa0b225e5a4a9d5ab4f0676202d3#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5f32daa9edf769fb5686e92ae6884e9fda6ea082452e4208309fc14301e26aef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -5578,9 +5578,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "1.1.3+spec-1.1.0"
|
version = "1.1.4+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
|
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
|||||||
+7
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumi_tui"
|
name = "furumi_tui"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.97"
|
rust-version = "1.97"
|
||||||
description = "A federated P2P player for personal music libraries"
|
description = "A federated P2P player for personal music libraries"
|
||||||
@@ -21,7 +21,7 @@ image = { version = "0.25.10", default-features = false, features = ["jpeg", "pn
|
|||||||
lofty = "0.22"
|
lofty = "0.22"
|
||||||
# P2P federation: library index in a shared DHT + audio streaming between
|
# P2P federation: library index in a shared DHT + audio streaming between
|
||||||
# peers (same protocol as furumi-fd).
|
# peers (same protocol as furumi-fd).
|
||||||
music-dht = { git = "https://gt.hexor.cy/ab/frid.git", rev = "8de7d1292708fa0b225e5a4a9d5ab4f0676202d3" }
|
music-dht = "0.3"
|
||||||
ratatui = "0.30.1"
|
ratatui = "0.30.1"
|
||||||
rhai = { version = "1", features = ["sync"] }
|
rhai = { version = "1", features = ["sync"] }
|
||||||
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
|
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
|
||||||
@@ -36,6 +36,11 @@ tracing = "0.1.44"
|
|||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
unicode-width = "0.2.2"
|
unicode-width = "0.2.2"
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
# souvlaki 0.8.3 still depends on the unmaintained block 0.1.6. Keep its API
|
||||||
|
# intact while using an opaque inhabited FFI type accepted by current Rust.
|
||||||
|
block = { path = "vendor/block" }
|
||||||
|
|
||||||
[target.'cfg(target_os="macos")'.dependencies]
|
[target.'cfg(target_os="macos")'.dependencies]
|
||||||
core-foundation = "0.10.1"
|
core-foundation = "0.10.1"
|
||||||
|
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ pub async fn run(
|
|||||||
state.device_playback.self_device_name = device_name.clone();
|
state.device_playback.self_device_name = device_name.clone();
|
||||||
state.device_playback.active_device_id = Some(device_id);
|
state.device_playback.active_device_id = Some(device_id);
|
||||||
state.device_playback.active_device_name = Some(device_name);
|
state.device_playback.active_device_name = Some(device_name);
|
||||||
|
state.device_playback.startup_takeover_pending = true;
|
||||||
}
|
}
|
||||||
let player_events = event_tx.clone();
|
let player_events = event_tx.clone();
|
||||||
let mut runtime = Runtime {
|
let mut runtime = Runtime {
|
||||||
@@ -2811,6 +2812,18 @@ fn handle_device_playback_snapshot(
|
|||||||
if !snapshot.active {
|
if !snapshot.active {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Starting a player is an explicit claim of the active role. Import the
|
||||||
|
// current queue/position from the previously active peer, then announce a
|
||||||
|
// normal handoff so that the old owner becomes a control device. This is
|
||||||
|
// intentionally one-shot: subsequent snapshots use the regular lease and
|
||||||
|
// explicit-transfer rules.
|
||||||
|
if state.device_playback.startup_takeover_pending {
|
||||||
|
state.device_playback.startup_takeover_pending = false;
|
||||||
|
become_control_device(state, runtime, snapshot);
|
||||||
|
transfer_active_to_this_device(state, runtime);
|
||||||
|
state.status_message = Some("playback moved to this newly started player".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
if local_active_lease_protected(state, now) {
|
if local_active_lease_protected(state, now) {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
remote = %snapshot.device_id,
|
remote = %snapshot.device_id,
|
||||||
|
|||||||
@@ -1240,6 +1240,9 @@ pub struct DevicePlaybackState {
|
|||||||
pub remote: BTreeMap<String, crate::devices::PlaybackSnapshot>,
|
pub remote: BTreeMap<String, crate::devices::PlaybackSnapshot>,
|
||||||
pub last_remote_snapshot: Option<crate::devices::PlaybackSnapshot>,
|
pub last_remote_snapshot: Option<crate::devices::PlaybackSnapshot>,
|
||||||
pub jam_host: bool,
|
pub jam_host: bool,
|
||||||
|
/// A freshly started TUI owns playback by protocol. The first active
|
||||||
|
/// snapshot discovered during startup is imported and handed off here.
|
||||||
|
pub startup_takeover_pending: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DevicePlaybackState {
|
impl DevicePlaybackState {
|
||||||
|
|||||||
+25
-5
@@ -24,7 +24,7 @@ use crate::library::models::{ArtistRef, TrackItem};
|
|||||||
|
|
||||||
pub const SYNC_ALPN: &[u8] = b"furumi/sync/2";
|
pub const SYNC_ALPN: &[u8] = b"furumi/sync/2";
|
||||||
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
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 INVITE_TTL_MS: i64 = 10 * 60 * 1000;
|
||||||
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
|
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
|
||||||
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
|
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
|
||||||
@@ -1249,8 +1249,7 @@ impl DeviceSync {
|
|||||||
device: &StoredDevice,
|
device: &StoredDevice,
|
||||||
transport_stats: Arc<crate::federation::TransportStats>,
|
transport_stats: Arc<crate::federation::TransportStats>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
|
let peer = resolve_device_peer(&service, device).await?;
|
||||||
let peer = service.connect(ticket).await?;
|
|
||||||
let own_ticket = service.ticket().await?.to_string();
|
let own_ticket = service.ticket().await?.to_string();
|
||||||
let identity = self.ensure_identity()?;
|
let identity = self.ensure_identity()?;
|
||||||
let profile = self.own_profile(&own_ticket)?;
|
let profile = self.own_profile(&own_ticket)?;
|
||||||
@@ -2380,7 +2379,7 @@ impl DeviceSync {
|
|||||||
let own = self.ensure_identity()?.device_id;
|
let own = self.ensure_identity()?.device_id;
|
||||||
let conn = lock(&self.conn);
|
let conn = lock(&self.conn);
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT device_id, endpoint_ticket
|
"SELECT device_id, endpoint_id, endpoint_ticket
|
||||||
FROM sync_devices
|
FROM sync_devices
|
||||||
WHERE trusted_at_ms IS NOT NULL
|
WHERE trusted_at_ms IS NOT NULL
|
||||||
AND revoked_at_ms IS NULL
|
AND revoked_at_ms IS NULL
|
||||||
@@ -2390,7 +2389,8 @@ impl DeviceSync {
|
|||||||
let rows = stmt.query_map([own], |row| {
|
let rows = stmt.query_map([own], |row| {
|
||||||
Ok(StoredDevice {
|
Ok(StoredDevice {
|
||||||
device_id: row.get(0)?,
|
device_id: row.get(0)?,
|
||||||
endpoint_ticket: row.get(1)?,
|
endpoint_id: row.get(1)?,
|
||||||
|
endpoint_ticket: row.get(2)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
@@ -2642,9 +2642,29 @@ impl DeviceSync {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct StoredDevice {
|
struct StoredDevice {
|
||||||
device_id: String,
|
device_id: String,
|
||||||
|
endpoint_id: String,
|
||||||
endpoint_ticket: String,
|
endpoint_ticket: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_device_peer(
|
||||||
|
service: &MusicDhtService,
|
||||||
|
device: &StoredDevice,
|
||||||
|
) -> Result<music_dht::EndpointId> {
|
||||||
|
if let Ok(peer) = device.endpoint_id.parse::<music_dht::EndpointId>()
|
||||||
|
&& (service.connected_peers().contains(&peer)
|
||||||
|
|| service
|
||||||
|
.known_peers()
|
||||||
|
.iter()
|
||||||
|
.any(|contact| contact.peer_id == peer))
|
||||||
|
{
|
||||||
|
// The live DHT contact carries a ticket for the current schema. The
|
||||||
|
// persisted device ticket may predate a schema upgrade.
|
||||||
|
return Ok(peer);
|
||||||
|
}
|
||||||
|
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
|
||||||
|
service.connect(ticket).await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn serve_peers(
|
pub async fn serve_peers(
|
||||||
mut acceptor: StreamAcceptor,
|
mut acceptor: StreamAcceptor,
|
||||||
sync: Arc<DeviceSync>,
|
sync: Arc<DeviceSync>,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ use crate::library::Library;
|
|||||||
|
|
||||||
/// ALPN of the audio streaming protocol (shared with furumi-fd).
|
/// ALPN of the audio streaming protocol (shared with furumi-fd).
|
||||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
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).
|
/// Maximum size of a JSON protocol line (request or response header).
|
||||||
const MAX_PROTOCOL_LINE: usize = 4096;
|
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
//! the network too).
|
//! the network too).
|
||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
|
mod capabilities;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
|
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
@@ -36,6 +37,7 @@ use crate::library::NetworkArtistPreview;
|
|||||||
use crate::library::models::{ArtistRef, TrackItem};
|
use crate::library::models::{ArtistRef, TrackItem};
|
||||||
|
|
||||||
pub use audio::{AUDIO_ALPN, DownloadProgress, StreamingStart, TrackMetadata};
|
pub use audio::{AUDIO_ALPN, DownloadProgress, StreamingStart, TrackMetadata};
|
||||||
|
pub use capabilities::ProtocolVersions;
|
||||||
pub use catalog::{CATALOG_ALPN, FedAppearsOn, FedArtistCard, FedCardTrack, FedRelease};
|
pub use catalog::{CATALOG_ALPN, FedAppearsOn, FedArtistCard, FedCardTrack, FedRelease};
|
||||||
|
|
||||||
/// How often the published library is re-synchronized with the local index.
|
/// 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_sync: Option<String>,
|
||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
pub transport: TransportStatsSnapshot,
|
pub transport: TransportStatsSnapshot,
|
||||||
|
pub protocols: ProtocolVersions,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Outcome of preparing a federated track for playback.
|
/// Outcome of preparing a federated track for playback.
|
||||||
@@ -426,6 +429,7 @@ pub struct Federation {
|
|||||||
last_sync: std::sync::Mutex<Option<String>>,
|
last_sync: std::sync::Mutex<Option<String>>,
|
||||||
last_error: std::sync::Mutex<Option<String>>,
|
last_error: std::sync::Mutex<Option<String>>,
|
||||||
transport_stats: Arc<TransportStats>,
|
transport_stats: Arc<TransportStats>,
|
||||||
|
observed_protocols: Arc<capabilities::ObservedVersions>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -554,6 +558,7 @@ impl Federation {
|
|||||||
last_sync: std::sync::Mutex::new(None),
|
last_sync: std::sync::Mutex::new(None),
|
||||||
last_error: std::sync::Mutex::new(initial_error),
|
last_error: std::sync::Mutex::new(initial_error),
|
||||||
transport_stats: Arc::new(TransportStats::default()),
|
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)
|
.stream_protocol(crate::devices::SYNC_ALPN)
|
||||||
// Capability-scoped shared playback control.
|
// Capability-scoped shared playback control.
|
||||||
.stream_protocol(crate::jam::JAM_ALPN)
|
.stream_protocol(crate::jam::JAM_ALPN)
|
||||||
|
// Informational application/protocol versions.
|
||||||
|
.schema_independent_stream_protocol(capabilities::CAPABILITIES_ALPN)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||||
let (service, mut events) = MusicDhtService::start(config)
|
let (service, mut events) = MusicDhtService::start(config)
|
||||||
@@ -728,6 +735,14 @@ impl Federation {
|
|||||||
Arc::clone(&self.jam),
|
Arc::clone(&self.jam),
|
||||||
Arc::clone(&service),
|
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 {
|
*guard = Some(Running {
|
||||||
service,
|
service,
|
||||||
@@ -742,6 +757,8 @@ impl Federation {
|
|||||||
device_tick_task,
|
device_tick_task,
|
||||||
jam_serve_task,
|
jam_serve_task,
|
||||||
jam_poll_task,
|
jam_poll_task,
|
||||||
|
capabilities_serve_task,
|
||||||
|
capabilities_probe_task,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
self.set_error(None);
|
self.set_error(None);
|
||||||
@@ -880,6 +897,7 @@ impl Federation {
|
|||||||
network: settings.network_id,
|
network: settings.network_id,
|
||||||
last_sync: lock(&self.last_sync).clone(),
|
last_sync: lock(&self.last_sync).clone(),
|
||||||
last_error: lock(&self.last_error).clone(),
|
last_error: lock(&self.last_error).clone(),
|
||||||
|
protocols: ProtocolVersions::snapshot(&self.observed_protocols),
|
||||||
..FedStatus::default()
|
..FedStatus::default()
|
||||||
};
|
};
|
||||||
if let Some(running) = guard.as_ref() {
|
if let Some(running) = guard.as_ref() {
|
||||||
|
|||||||
+1
-5
@@ -13,7 +13,7 @@ use crate::app::event::AppEvent;
|
|||||||
use crate::devices::{PlaybackCommand, PlaybackSnapshot};
|
use crate::devices::{PlaybackCommand, PlaybackSnapshot};
|
||||||
|
|
||||||
pub const JAM_ALPN: &[u8] = b"furumi/jam/1";
|
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_LINE: usize = 8 * 1024 * 1024;
|
||||||
const MAX_COMMANDS: usize = 128;
|
const MAX_COMMANDS: usize = 128;
|
||||||
const PARTICIPANT_TTL_MS: i64 = 30 * 60 * 1_000;
|
const PARTICIPANT_TTL_MS: i64 = 30 * 60 * 1_000;
|
||||||
@@ -29,7 +29,6 @@ pub enum JamRole {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct JamStatus {
|
pub struct JamStatus {
|
||||||
pub role: JamRole,
|
pub role: JamRole,
|
||||||
pub jam_id: Option<String>,
|
|
||||||
pub host_name: Option<String>,
|
pub host_name: Option<String>,
|
||||||
pub invite: Option<String>,
|
pub invite: Option<String>,
|
||||||
pub participants: Vec<JamParticipant>,
|
pub participants: Vec<JamParticipant>,
|
||||||
@@ -41,7 +40,6 @@ impl Default for JamStatus {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
role: JamRole::None,
|
role: JamRole::None,
|
||||||
jam_id: None,
|
|
||||||
host_name: None,
|
host_name: None,
|
||||||
invite: None,
|
invite: None,
|
||||||
participants: Vec::new(),
|
participants: Vec::new(),
|
||||||
@@ -207,7 +205,6 @@ impl JamManager {
|
|||||||
if let Some(joined) = &state.joined {
|
if let Some(joined) = &state.joined {
|
||||||
return JamStatus {
|
return JamStatus {
|
||||||
role: JamRole::Participant,
|
role: JamRole::Participant,
|
||||||
jam_id: Some(joined.invite.jam_id.clone()),
|
|
||||||
host_name: Some(joined.invite.host_name.clone()),
|
host_name: Some(joined.invite.host_name.clone()),
|
||||||
invite: None,
|
invite: None,
|
||||||
participants: joined.participants.clone(),
|
participants: joined.participants.clone(),
|
||||||
@@ -218,7 +215,6 @@ impl JamManager {
|
|||||||
if let Some(invite) = &state.host.invite {
|
if let Some(invite) = &state.host.invite {
|
||||||
return JamStatus {
|
return JamStatus {
|
||||||
role: JamRole::Host,
|
role: JamRole::Host,
|
||||||
jam_id: Some(invite.jam_id.clone()),
|
|
||||||
host_name: Some(invite.host_name.clone()),
|
host_name: Some(invite.host_name.clone()),
|
||||||
invite: state.host.invite_uri.clone(),
|
invite: state.host.invite_uri.clone(),
|
||||||
participants: state.host.participants.values().cloned().collect(),
|
participants: state.host.participants.values().cloned().collect(),
|
||||||
|
|||||||
+164
-3
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Paragraph};
|
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) {
|
fn draw_section(frame: &mut Frame, area: Rect, state: &AppState, y: &mut u16, title: &'static str) {
|
||||||
if *y >= area.y + area.height {
|
if *y >= area.y + area.height {
|
||||||
return;
|
return;
|
||||||
@@ -485,11 +500,16 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if area.width >= 60 && area.height >= 15 {
|
if area.width >= 60 && area.height >= 20 {
|
||||||
let [top_area, _, bottom_area] = Layout::vertical([
|
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(7),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Length(7),
|
Constraint::Length(7),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(protocols_height),
|
||||||
|
Constraint::Min(0),
|
||||||
])
|
])
|
||||||
.areas(area);
|
.areas(area);
|
||||||
let [status_area, _, local_area] = Layout::horizontal([
|
let [status_area, _, local_area] = Layout::horizontal([
|
||||||
@@ -532,10 +552,17 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
" Connected Devices ",
|
" Connected Devices ",
|
||||||
device_summary_lines(state),
|
device_summary_lines(state),
|
||||||
);
|
);
|
||||||
|
draw_summary_card(
|
||||||
|
frame,
|
||||||
|
protocols_area,
|
||||||
|
state,
|
||||||
|
" Protocol Versions ",
|
||||||
|
protocol_summary_lines(state, protocols_area.width.saturating_sub(2)),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if area.height < 31 {
|
if area.height < 39 {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(compact_status_lines(state))
|
Paragraph::new(compact_status_lines(state))
|
||||||
.wrap(ratatui::widgets::Wrap { trim: false }),
|
.wrap(ratatui::widgets::Wrap { trim: false }),
|
||||||
@@ -553,6 +580,8 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
_,
|
_,
|
||||||
local_area,
|
local_area,
|
||||||
_,
|
_,
|
||||||
|
protocols_area,
|
||||||
|
_,
|
||||||
] = Layout::vertical([
|
] = Layout::vertical([
|
||||||
Constraint::Length(7),
|
Constraint::Length(7),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
@@ -561,6 +590,12 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
Constraint::Length(7),
|
Constraint::Length(7),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Length(7),
|
Constraint::Length(7),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(protocol_card_height(
|
||||||
|
state,
|
||||||
|
area.width.saturating_sub(2),
|
||||||
|
area.height,
|
||||||
|
)),
|
||||||
Constraint::Min(0),
|
Constraint::Min(0),
|
||||||
])
|
])
|
||||||
.areas(area);
|
.areas(area);
|
||||||
@@ -593,6 +628,13 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
" Local Data ",
|
" Local Data ",
|
||||||
local_data_summary_lines(state),
|
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>> {
|
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::default());
|
||||||
lines.push(Line::styled("Connected Devices", theme::header_for(state)));
|
lines.push(Line::styled("Connected Devices", theme::header_for(state)));
|
||||||
lines.extend(device_summary_lines(state).into_iter().take(2));
|
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
|
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>> {
|
fn node_summary_lines(state: &AppState) -> Vec<Line<'static>> {
|
||||||
match &state.federation.status {
|
match &state.federation.status {
|
||||||
None => vec![
|
None => vec![
|
||||||
|
|||||||
Vendored
+23
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "block"
|
||||||
|
version = "0.1.6"
|
||||||
|
authors = ["Steven Sheldon"]
|
||||||
|
|
||||||
|
description = "Rust interface for Apple's C language extension of blocks."
|
||||||
|
keywords = ["blocks", "osx", "ios", "objective-c"]
|
||||||
|
readme = "README.md"
|
||||||
|
repository = "http://github.com/SSheldon/rust-block"
|
||||||
|
documentation = "http://ssheldon.github.io/rust-objc/block/"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
exclude = [
|
||||||
|
".gitignore",
|
||||||
|
".travis.yml",
|
||||||
|
"travis_install.sh",
|
||||||
|
"travis_test.sh",
|
||||||
|
"tests-ios/**",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dev-dependencies.objc_test_utils]
|
||||||
|
version = "0.0"
|
||||||
|
path = "test_utils"
|
||||||
Vendored
+42
@@ -0,0 +1,42 @@
|
|||||||
|
Rust interface for Apple's C language extension of blocks.
|
||||||
|
|
||||||
|
For more information on the specifics of the block implementation, see
|
||||||
|
Clang's documentation: http://clang.llvm.org/docs/Block-ABI-Apple.html
|
||||||
|
|
||||||
|
## Invoking blocks
|
||||||
|
|
||||||
|
The `Block` struct is used for invoking blocks from Objective-C. For example,
|
||||||
|
consider this Objective-C function:
|
||||||
|
|
||||||
|
``` objc
|
||||||
|
int32_t sum(int32_t (^block)(int32_t, int32_t)) {
|
||||||
|
return block(5, 8);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We could write it in Rust as the following:
|
||||||
|
|
||||||
|
``` rust
|
||||||
|
unsafe fn sum(block: &Block<(i32, i32), i32>) -> i32 {
|
||||||
|
block.call((5, 8))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the extra parentheses in the `call` method, since the arguments must be
|
||||||
|
passed as a tuple.
|
||||||
|
|
||||||
|
## Creating blocks
|
||||||
|
|
||||||
|
Creating a block to pass to Objective-C can be done with the `ConcreteBlock`
|
||||||
|
struct. For example, to create a block that adds two `i32`s, we could write:
|
||||||
|
|
||||||
|
``` rust
|
||||||
|
let block = ConcreteBlock::new(|a: i32, b: i32| a + b);
|
||||||
|
let block = block.copy();
|
||||||
|
assert!(unsafe { block.call((5, 8)) } == 13);
|
||||||
|
```
|
||||||
|
|
||||||
|
It is important to copy your block to the heap (with the `copy` method) before
|
||||||
|
passing it to Objective-C; this is because our `ConcreteBlock` is only meant
|
||||||
|
to be copied once, and we can enforce this in Rust, but if Objective-C code
|
||||||
|
were to copy it twice we could have a double free.
|
||||||
Vendored
+399
@@ -0,0 +1,399 @@
|
|||||||
|
/*!
|
||||||
|
A Rust interface for Objective-C blocks.
|
||||||
|
|
||||||
|
For more information on the specifics of the block implementation, see
|
||||||
|
Clang's documentation: http://clang.llvm.org/docs/Block-ABI-Apple.html
|
||||||
|
|
||||||
|
# Invoking blocks
|
||||||
|
|
||||||
|
The `Block` struct is used for invoking blocks from Objective-C. For example,
|
||||||
|
consider this Objective-C function:
|
||||||
|
|
||||||
|
``` objc
|
||||||
|
int32_t sum(int32_t (^block)(int32_t, int32_t)) {
|
||||||
|
return block(5, 8);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We could write it in Rust as the following:
|
||||||
|
|
||||||
|
```
|
||||||
|
# use block::Block;
|
||||||
|
unsafe fn sum(block: &Block<(i32, i32), i32>) -> i32 {
|
||||||
|
block.call((5, 8))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the extra parentheses in the `call` method, since the arguments must be
|
||||||
|
passed as a tuple.
|
||||||
|
|
||||||
|
# Creating blocks
|
||||||
|
|
||||||
|
Creating a block to pass to Objective-C can be done with the `ConcreteBlock`
|
||||||
|
struct. For example, to create a block that adds two `i32`s, we could write:
|
||||||
|
|
||||||
|
```
|
||||||
|
# use block::ConcreteBlock;
|
||||||
|
let block = ConcreteBlock::new(|a: i32, b: i32| a + b);
|
||||||
|
let block = block.copy();
|
||||||
|
assert!(unsafe { block.call((5, 8)) } == 13);
|
||||||
|
```
|
||||||
|
|
||||||
|
It is important to copy your block to the heap (with the `copy` method) before
|
||||||
|
passing it to Objective-C; this is because our `ConcreteBlock` is only meant
|
||||||
|
to be copied once, and we can enforce this in Rust, but if Objective-C code
|
||||||
|
were to copy it twice we could have a double free.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test_utils;
|
||||||
|
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
use std::mem;
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
|
use std::os::raw::{c_int, c_ulong, c_void};
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct Class {
|
||||||
|
_private: [u8; 0],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(any(target_os = "macos", target_os = "ios"),
|
||||||
|
link(name = "System", kind = "dylib"))]
|
||||||
|
#[cfg_attr(not(any(target_os = "macos", target_os = "ios")),
|
||||||
|
link(name = "BlocksRuntime", kind = "dylib"))]
|
||||||
|
extern "C" {
|
||||||
|
static _NSConcreteStackBlock: Class;
|
||||||
|
|
||||||
|
fn _Block_copy(block: *const c_void) -> *mut c_void;
|
||||||
|
fn _Block_release(block: *const c_void);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Types that may be used as the arguments to an Objective-C block.
|
||||||
|
pub trait BlockArguments: Sized {
|
||||||
|
/// Calls the given `Block` with self as the arguments.
|
||||||
|
///
|
||||||
|
/// Unsafe because `block` must point to a valid `Block` and this invokes
|
||||||
|
/// foreign code whose safety the compiler cannot verify.
|
||||||
|
unsafe fn call_block<R>(self, block: *mut Block<Self, R>) -> R;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! block_args_impl {
|
||||||
|
($($a:ident : $t:ident),*) => (
|
||||||
|
impl<$($t),*> BlockArguments for ($($t,)*) {
|
||||||
|
unsafe fn call_block<R>(self, block: *mut Block<Self, R>) -> R {
|
||||||
|
let invoke: unsafe extern "C" fn(*mut Block<Self, R> $(, $t)*) -> R = {
|
||||||
|
let base = block as *mut BlockBase<Self, R>;
|
||||||
|
mem::transmute((*base).invoke)
|
||||||
|
};
|
||||||
|
let ($($a,)*) = self;
|
||||||
|
invoke(block $(, $a)*)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
block_args_impl!();
|
||||||
|
block_args_impl!(a: A);
|
||||||
|
block_args_impl!(a: A, b: B);
|
||||||
|
block_args_impl!(a: A, b: B, c: C);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K, l: L);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct BlockBase<A, R> {
|
||||||
|
isa: *const Class,
|
||||||
|
flags: c_int,
|
||||||
|
_reserved: c_int,
|
||||||
|
invoke: unsafe extern "C" fn(*mut Block<A, R>, ...) -> R,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An Objective-C block that takes arguments of `A` when called and
|
||||||
|
/// returns a value of `R`.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct Block<A, R> {
|
||||||
|
_base: PhantomData<BlockBase<A, R>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A: BlockArguments, R> Block<A, R> where A: BlockArguments {
|
||||||
|
/// Call self with the given arguments.
|
||||||
|
///
|
||||||
|
/// Unsafe because this invokes foreign code that the caller must verify
|
||||||
|
/// doesn't violate any of Rust's safety rules. For example, if this block
|
||||||
|
/// is shared with multiple references, the caller must ensure that calling
|
||||||
|
/// it will not cause a data race.
|
||||||
|
pub unsafe fn call(&self, args: A) -> R {
|
||||||
|
args.call_block(self as *const _ as *mut _)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reference-counted Objective-C block.
|
||||||
|
pub struct RcBlock<A, R> {
|
||||||
|
ptr: *mut Block<A, R>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R> RcBlock<A, R> {
|
||||||
|
/// Construct an `RcBlock` for the given block without copying it.
|
||||||
|
/// The caller must ensure the block has a +1 reference count.
|
||||||
|
///
|
||||||
|
/// Unsafe because `ptr` must point to a valid `Block` and must have a +1
|
||||||
|
/// reference count or it will be overreleased when the `RcBlock` is
|
||||||
|
/// dropped.
|
||||||
|
pub unsafe fn new(ptr: *mut Block<A, R>) -> Self {
|
||||||
|
RcBlock { ptr: ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs an `RcBlock` by copying the given block.
|
||||||
|
///
|
||||||
|
/// Unsafe because `ptr` must point to a valid `Block`.
|
||||||
|
pub unsafe fn copy(ptr: *mut Block<A, R>) -> Self {
|
||||||
|
let ptr = _Block_copy(ptr as *const c_void) as *mut Block<A, R>;
|
||||||
|
RcBlock { ptr: ptr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R> Clone for RcBlock<A, R> {
|
||||||
|
fn clone(&self) -> RcBlock<A, R> {
|
||||||
|
unsafe {
|
||||||
|
RcBlock::copy(self.ptr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R> Deref for RcBlock<A, R> {
|
||||||
|
type Target = Block<A, R>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Block<A, R> {
|
||||||
|
unsafe { &*self.ptr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R> Drop for RcBlock<A, R> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
_Block_release(self.ptr as *const c_void);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Types that may be converted into a `ConcreteBlock`.
|
||||||
|
pub trait IntoConcreteBlock<A>: Sized where A: BlockArguments {
|
||||||
|
/// The return type of the resulting `ConcreteBlock`.
|
||||||
|
type Ret;
|
||||||
|
|
||||||
|
/// Consumes self to create a `ConcreteBlock`.
|
||||||
|
fn into_concrete_block(self) -> ConcreteBlock<A, Self::Ret, Self>;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! concrete_block_impl {
|
||||||
|
($f:ident) => (
|
||||||
|
concrete_block_impl!($f,);
|
||||||
|
);
|
||||||
|
($f:ident, $($a:ident : $t:ident),*) => (
|
||||||
|
impl<$($t,)* R, X> IntoConcreteBlock<($($t,)*)> for X
|
||||||
|
where X: Fn($($t,)*) -> R {
|
||||||
|
type Ret = R;
|
||||||
|
|
||||||
|
fn into_concrete_block(self) -> ConcreteBlock<($($t,)*), R, X> {
|
||||||
|
unsafe extern "C" fn $f<$($t,)* R, X>(
|
||||||
|
block_ptr: *mut ConcreteBlock<($($t,)*), R, X>
|
||||||
|
$(, $a: $t)*) -> R
|
||||||
|
where X: Fn($($t,)*) -> R {
|
||||||
|
let block = &*block_ptr;
|
||||||
|
(block.closure)($($a),*)
|
||||||
|
}
|
||||||
|
|
||||||
|
let f: unsafe extern "C" fn(*mut ConcreteBlock<($($t,)*), R, X> $(, $a: $t)*) -> R = $f;
|
||||||
|
unsafe {
|
||||||
|
ConcreteBlock::with_invoke(mem::transmute(f), self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args0);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args1, a: A);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args2, a: A, b: B);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args3, a: A, b: B, c: C);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args4, a: A, b: B, c: C, d: D);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args5, a: A, b: B, c: C, d: D, e: E);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args6, a: A, b: B, c: C, d: D, e: E, f: F);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args7, a: A, b: B, c: C, d: D, e: E, f: F, g: G);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args8, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args9, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args10, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args11, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args12, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K, l: L);
|
||||||
|
|
||||||
|
/// An Objective-C block whose size is known at compile time and may be
|
||||||
|
/// constructed on the stack.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct ConcreteBlock<A, R, F> {
|
||||||
|
base: BlockBase<A, R>,
|
||||||
|
descriptor: Box<BlockDescriptor<ConcreteBlock<A, R, F>>>,
|
||||||
|
closure: F,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R, F> ConcreteBlock<A, R, F>
|
||||||
|
where A: BlockArguments, F: IntoConcreteBlock<A, Ret=R> {
|
||||||
|
/// Constructs a `ConcreteBlock` with the given closure.
|
||||||
|
/// When the block is called, it will return the value that results from
|
||||||
|
/// calling the closure.
|
||||||
|
pub fn new(closure: F) -> Self {
|
||||||
|
closure.into_concrete_block()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R, F> ConcreteBlock<A, R, F> {
|
||||||
|
/// Constructs a `ConcreteBlock` with the given invoke function and closure.
|
||||||
|
/// Unsafe because the caller must ensure the invoke function takes the
|
||||||
|
/// correct arguments.
|
||||||
|
unsafe fn with_invoke(invoke: unsafe extern "C" fn(*mut Self, ...) -> R,
|
||||||
|
closure: F) -> Self {
|
||||||
|
ConcreteBlock {
|
||||||
|
base: BlockBase {
|
||||||
|
isa: &_NSConcreteStackBlock,
|
||||||
|
// 1 << 25 = BLOCK_HAS_COPY_DISPOSE
|
||||||
|
flags: 1 << 25,
|
||||||
|
_reserved: 0,
|
||||||
|
invoke: mem::transmute(invoke),
|
||||||
|
},
|
||||||
|
descriptor: Box::new(BlockDescriptor::new()),
|
||||||
|
closure: closure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R, F> ConcreteBlock<A, R, F> where F: 'static {
|
||||||
|
/// Copy self onto the heap as an `RcBlock`.
|
||||||
|
pub fn copy(self) -> RcBlock<A, R> {
|
||||||
|
unsafe {
|
||||||
|
let mut block = self;
|
||||||
|
let copied = RcBlock::copy(&mut *block);
|
||||||
|
// At this point, our copy helper has been run so the block will
|
||||||
|
// be moved to the heap and we can forget the original block
|
||||||
|
// because the heap block will drop in our dispose helper.
|
||||||
|
mem::forget(block);
|
||||||
|
copied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R, F> Clone for ConcreteBlock<A, R, F> where F: Clone {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
unsafe {
|
||||||
|
ConcreteBlock::with_invoke(mem::transmute(self.base.invoke),
|
||||||
|
self.closure.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R, F> Deref for ConcreteBlock<A, R, F> {
|
||||||
|
type Target = Block<A, R>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Block<A, R> {
|
||||||
|
unsafe { &*(&self.base as *const _ as *const Block<A, R>) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R, F> DerefMut for ConcreteBlock<A, R, F> {
|
||||||
|
fn deref_mut(&mut self) -> &mut Block<A, R> {
|
||||||
|
unsafe { &mut *(&mut self.base as *mut _ as *mut Block<A, R>) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" fn block_context_dispose<B>(block: &mut B) {
|
||||||
|
// Read the block onto the stack and let it drop
|
||||||
|
ptr::read(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" fn block_context_copy<B>(_dst: &mut B, _src: &B) {
|
||||||
|
// The runtime memmoves the src block into the dst block, nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct BlockDescriptor<B> {
|
||||||
|
_reserved: c_ulong,
|
||||||
|
block_size: c_ulong,
|
||||||
|
copy_helper: unsafe extern "C" fn(&mut B, &B),
|
||||||
|
dispose_helper: unsafe extern "C" fn(&mut B),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B> BlockDescriptor<B> {
|
||||||
|
fn new() -> BlockDescriptor<B> {
|
||||||
|
BlockDescriptor {
|
||||||
|
_reserved: 0,
|
||||||
|
block_size: mem::size_of::<B>() as c_ulong,
|
||||||
|
copy_helper: block_context_copy::<B>,
|
||||||
|
dispose_helper: block_context_dispose::<B>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use test_utils::*;
|
||||||
|
use super::{ConcreteBlock, RcBlock};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_call_block() {
|
||||||
|
let block = get_int_block_with(13);
|
||||||
|
unsafe {
|
||||||
|
assert!(block.call(()) == 13);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_call_block_args() {
|
||||||
|
let block = get_add_block_with(13);
|
||||||
|
unsafe {
|
||||||
|
assert!(block.call((2,)) == 15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_block() {
|
||||||
|
let block = ConcreteBlock::new(|| 13);
|
||||||
|
let result = invoke_int_block(&block);
|
||||||
|
assert!(result == 13);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_block_args() {
|
||||||
|
let block = ConcreteBlock::new(|a: i32| a + 5);
|
||||||
|
let result = invoke_add_block(&block, 6);
|
||||||
|
assert!(result == 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_concrete_block_copy() {
|
||||||
|
let s = "Hello!".to_string();
|
||||||
|
let expected_len = s.len() as i32;
|
||||||
|
let block = ConcreteBlock::new(move || s.len() as i32);
|
||||||
|
assert!(invoke_int_block(&block) == expected_len);
|
||||||
|
|
||||||
|
let copied = block.copy();
|
||||||
|
assert!(invoke_int_block(&copied) == expected_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_concrete_block_stack_copy() {
|
||||||
|
fn make_block() -> RcBlock<(), i32> {
|
||||||
|
let x = 7;
|
||||||
|
let block = ConcreteBlock::new(move || x);
|
||||||
|
block.copy()
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = make_block();
|
||||||
|
assert!(invoke_int_block(&block) == 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+31
@@ -0,0 +1,31 @@
|
|||||||
|
extern crate objc_test_utils;
|
||||||
|
|
||||||
|
use {Block, RcBlock};
|
||||||
|
|
||||||
|
pub fn get_int_block_with(i: i32) -> RcBlock<(), i32> {
|
||||||
|
unsafe {
|
||||||
|
let ptr = objc_test_utils::get_int_block_with(i);
|
||||||
|
RcBlock::new(ptr as *mut _)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_add_block_with(i: i32) -> RcBlock<(i32,), i32> {
|
||||||
|
unsafe {
|
||||||
|
let ptr = objc_test_utils::get_add_block_with(i);
|
||||||
|
RcBlock::new(ptr as *mut _)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invoke_int_block(block: &Block<(), i32>) -> i32 {
|
||||||
|
let ptr = block as *const _;
|
||||||
|
unsafe {
|
||||||
|
objc_test_utils::invoke_int_block(ptr as *mut _)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invoke_add_block(block: &Block<(i32,), i32>, a: i32) -> i32 {
|
||||||
|
let ptr = block as *const _;
|
||||||
|
unsafe {
|
||||||
|
objc_test_utils::invoke_add_block(ptr as *mut _, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user