diff --git a/Cargo.lock b/Cargo.lock index 56c6445..0352e01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -434,8 +434,6 @@ dependencies = [ [[package]] name = "block" version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" [[package]] name = "block-buffer" @@ -1211,13 +1209,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1454,8 +1452,9 @@ dependencies = [ [[package]] name = "federation-net" -version = "0.1.0" -source = "git+https://gt.hexor.cy/ab/frid.git?rev=8de7d1292708fa0b225e5a4a9d5ab4f0676202d3#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15a8707baeccb46b5935138f9cb3df3c988c0730b807a2634d901f26b39250d6" dependencies = [ "blake3", "data-encoding", @@ -2988,8 +2987,9 @@ dependencies = [ [[package]] name = "music-dht" -version = "0.2.0" -source = "git+https://gt.hexor.cy/ab/frid.git?rev=8de7d1292708fa0b225e5a4a9d5ab4f0676202d3#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f32daa9edf769fb5686e92ae6884e9fda6ea082452e4208309fc14301e26aef" dependencies = [ "async-trait", "blake3", @@ -5578,9 +5578,9 @@ dependencies = [ [[package]] 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" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", diff --git a/Cargo.toml b/Cargo.toml index c3ee4b6..c9959ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ image = { version = "0.25.10", default-features = false, features = ["jpeg", "pn lofty = "0.22" # P2P federation: library index in a shared DHT + audio streaming between # 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" 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"] } @@ -36,6 +36,11 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } 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] core-foundation = "0.10.1" diff --git a/src/devices.rs b/src/devices.rs index 626d436..cd72a07 100644 --- a/src/devices.rs +++ b/src/devices.rs @@ -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); diff --git a/src/federation/audio.rs b/src/federation/audio.rs index c53cab9..2553467 100644 --- a/src/federation/audio.rs +++ b/src/federation/audio.rs @@ -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; diff --git a/src/federation/capabilities.rs b/src/federation/capabilities.rs new file mode 100644 index 0000000..b3aa935 --- /dev/null +++ b/src/federation/capabilities.rs @@ -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, + pub observed: BTreeMap, + pub observed_peers: usize, + pub newer: Vec, +} + +#[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>, + peers: Mutex>, +} + +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, observed: Arc) { + 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::>(); + 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(mutex: &Mutex) -> 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"); + } +} diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 56f95a5..6e44335 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -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, pub last_error: Option, 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>, last_error: std::sync::Mutex>, transport_stats: Arc, + observed_protocols: Arc, } #[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() { diff --git a/src/jam.rs b/src/jam.rs index 495f865..248c586 100644 --- a/src/jam.rs +++ b/src/jam.rs @@ -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, pub host_name: Option, pub invite: Option, pub participants: Vec, @@ -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(), diff --git a/src/ui/federation.rs b/src/ui/federation.rs index a480667..02239ae 100644 --- a/src/ui/federation.rs +++ b/src/ui/federation.rs @@ -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> { @@ -608,6 +650,9 @@ fn compact_status_lines(state: &AppState) -> Vec> { 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!("{: Vec> { + 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> { + let value = value.chars().take(value_width).collect::(); + vec![ + Span::styled( + format!("{: Vec> { match &state.federation.status { None => vec![ diff --git a/vendor/block/Cargo.toml b/vendor/block/Cargo.toml new file mode 100644 index 0000000..f414e7e --- /dev/null +++ b/vendor/block/Cargo.toml @@ -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" diff --git a/vendor/block/README.md b/vendor/block/README.md new file mode 100644 index 0000000..78c65d0 --- /dev/null +++ b/vendor/block/README.md @@ -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. diff --git a/vendor/block/src/lib.rs b/vendor/block/src/lib.rs new file mode 100644 index 0000000..2f06a0f --- /dev/null +++ b/vendor/block/src/lib.rs @@ -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(self, block: *mut Block) -> R; +} + +macro_rules! block_args_impl { + ($($a:ident : $t:ident),*) => ( + impl<$($t),*> BlockArguments for ($($t,)*) { + unsafe fn call_block(self, block: *mut Block) -> R { + let invoke: unsafe extern "C" fn(*mut Block $(, $t)*) -> R = { + let base = block as *mut BlockBase; + 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 { + isa: *const Class, + flags: c_int, + _reserved: c_int, + invoke: unsafe extern "C" fn(*mut Block, ...) -> R, +} + +/// An Objective-C block that takes arguments of `A` when called and +/// returns a value of `R`. +#[repr(C)] +pub struct Block { + _base: PhantomData>, +} + +impl Block 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 { + ptr: *mut Block, +} + +impl RcBlock { + /// 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) -> 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) -> Self { + let ptr = _Block_copy(ptr as *const c_void) as *mut Block; + RcBlock { ptr: ptr } + } +} + +impl Clone for RcBlock { + fn clone(&self) -> RcBlock { + unsafe { + RcBlock::copy(self.ptr) + } + } +} + +impl Deref for RcBlock { + type Target = Block; + + fn deref(&self) -> &Block { + unsafe { &*self.ptr } + } +} + +impl Drop for RcBlock { + fn drop(&mut self) { + unsafe { + _Block_release(self.ptr as *const c_void); + } + } +} + +/// Types that may be converted into a `ConcreteBlock`. +pub trait IntoConcreteBlock: 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; +} + +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 { + base: BlockBase, + descriptor: Box>>, + closure: F, +} + +impl ConcreteBlock + where A: BlockArguments, F: IntoConcreteBlock { + /// 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 ConcreteBlock { + /// 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 ConcreteBlock where F: 'static { + /// Copy self onto the heap as an `RcBlock`. + pub fn copy(self) -> RcBlock { + 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 Clone for ConcreteBlock where F: Clone { + fn clone(&self) -> Self { + unsafe { + ConcreteBlock::with_invoke(mem::transmute(self.base.invoke), + self.closure.clone()) + } + } +} + +impl Deref for ConcreteBlock { + type Target = Block; + + fn deref(&self) -> &Block { + unsafe { &*(&self.base as *const _ as *const Block) } + } +} + +impl DerefMut for ConcreteBlock { + fn deref_mut(&mut self) -> &mut Block { + unsafe { &mut *(&mut self.base as *mut _ as *mut Block) } + } +} + +unsafe extern "C" fn block_context_dispose(block: &mut B) { + // Read the block onto the stack and let it drop + ptr::read(block); +} + +unsafe extern "C" fn block_context_copy(_dst: &mut B, _src: &B) { + // The runtime memmoves the src block into the dst block, nothing to do +} + +#[repr(C)] +struct BlockDescriptor { + _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 BlockDescriptor { + fn new() -> BlockDescriptor { + BlockDescriptor { + _reserved: 0, + block_size: mem::size_of::() as c_ulong, + copy_helper: block_context_copy::, + dispose_helper: block_context_dispose::, + } + } +} + +#[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); + } +} diff --git a/vendor/block/src/test_utils.rs b/vendor/block/src/test_utils.rs new file mode 100644 index 0000000..940a3d2 --- /dev/null +++ b/vendor/block/src/test_utils.rs @@ -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) + } +}