diff --git a/Cargo.lock b/Cargo.lock index 0276cff7..5374c234 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1243,6 +1243,7 @@ dependencies = [ "connetto-core", "connetto-peer", "connetto-peer-android", + "connetto-peer-apple", "connetto-schema", "connetto-server", "connetto-test-harness", @@ -1252,6 +1253,7 @@ dependencies = [ "diesel-sqlite-session", "futures-util", "getrandom 0.3.4", + "if-addrs", "ipnet", "jni 0.21.1", "js-sys", @@ -1459,6 +1461,24 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "connetto-peer-apple" +version = "0.0.0" +dependencies = [ + "block2", + "connetto-peer", + "dispatch2", + "if-addrs", + "libc", + "objc2", + "objc2-core-bluetooth", + "objc2-foundation", + "objc2-network-extension", + "objc2-ui-kit", + "parking_lot", + "thiserror 2.0.20", +] + [[package]] name = "connetto-schema" version = "0.0.0" @@ -5847,6 +5867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79b30b9eacc37434a61377866f68b27acef9fa5496f25cc9ca8b2549032e0394" dependencies = [ "bitflags 2.13.2", + "dispatch2", "objc2", "objc2-foundation", ] @@ -5920,6 +5941,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-network-extension" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9126ecdaea052ce2a6b7a894303d0806368b92c77165052da1c3eb52d9e5f9b1" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-system-configuration" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index 329c5425..ea9365f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/connetto-app-attest", "crates/connetto-peer", "crates/connetto-peer-android", + "crates/connetto-peer-apple", # The documents and the build-time translation every browser demo shares. # A member rather than an exclusion, so the root gate lints it with the rest. "examples/deployment", diff --git a/codecov.yml b/codecov.yml index 688a5a8d..87acf812 100644 --- a/codecov.yml +++ b/codecov.yml @@ -3,14 +3,15 @@ # binaries are outside that set: the browser stack only ever runs inside the # browser test workflow, which compiles for wasm and reports no native # coverage at all, and the demo stack and Android proof need a device. Their -# shared provisioning module is reached from nothing else. The wasm crate, the -# examples and the Rust that builds only for Android, iOS, Windows, macOS +# shared provisioning module and phone-driving steps are reached from nothing +# else. The wasm crate, the examples and the Rust that builds only for Android, iOS, Windows, macOS # or wasm are outside it too. So these lines are unreachable by any instrument, not missing tests. # sonar-project.properties excludes the same paths, which the two tools share. coverage: ignore: - crates/connetto-test-harness/src/bin/** - crates/connetto-test-harness/src/stack.rs + - crates/connetto-test-harness/src/peer_devices.rs - crates/connetto-web/** - examples/** - crates/connetto-app-attest/src/ios.rs diff --git a/crates/connetto-client/Cargo.toml b/crates/connetto-client/Cargo.toml index c26e923c..51c276bf 100644 --- a/crates/connetto-client/Cargo.toml +++ b/crates/connetto-client/Cargo.toml @@ -48,7 +48,13 @@ bot = ["dep:ring"] device-identity = ["native-auth", "connetto-core/device-cert", "dep:ring", "dep:core-foundation", "dep:rmp-serde", "dep:serde_bytes", "dep:connetto-app-attest"] # The peer link (R76): the device serves its identity to other devices over # a second, server-independent channel, and dials them. -peer = ["device-identity", "dep:connetto-peer", "dep:btleplug", "dep:connetto-peer-android"] +peer = [ + "device-identity", + "dep:connetto-peer", + "dep:btleplug", + "dep:connetto-peer-android", + "dep:connetto-peer-apple", +] native-auth = [ "native-transport", "dep:reqwest", @@ -217,6 +223,12 @@ apple-native-keyring-store = { version = "1.0.2", features = [ ], optional = true } security-framework = { version = "3.7", optional = true } core-foundation = { version = "0.10", optional = true } +# The peer's Apple bridge, the peripheral and the Network Extension join (R76). +connetto-peer-apple = { path = "../connetto-peer-apple", optional = true } +# The interfaces the personal hotspot's landing bound reads (R76 decision 26). +if-addrs = "0.15" +# The streams btleplug's central answers with (R76 decision 22). +futures-util = { version = "0.3", default-features = false, features = ["std"], optional = true } [target.'cfg(target_os = "linux")'.dependencies] linux-keyutils-keyring-store = { version = "1.0.0", optional = true } @@ -246,6 +258,9 @@ open = { version = "5.4", optional = true } # iOS. [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] webbrowser = { version = "1.2", optional = true } +# The peer's Bluetooth, the central the joiner runs on Android and on the +# Apple side through CoreBluetooth (R76 decision 22). +btleplug = { version = "0.13.5", optional = true } # Android: the Keystore-backed store of the same family. The NDK ships no # OpenSSL headers, so SQLCipher builds its own from source here. @@ -260,9 +275,6 @@ android_system_properties = { version = "0.1.6", optional = true } socket2 = "0.6" # The joined network's subnet, the targets its bind admits (R76). ipnet = "2.12" -# The joiner's Bluetooth central (R76 decision 22), from the release whose -# Android init resolves its classes through the context class loader. -btleplug = { version = "0.13.5", optional = true } # The jni 0.22 virtual machine btleplug's init takes, and btleplug's Java # bundled in its Gradle module (R76 decision 22). connetto-peer-android = { path = "../connetto-peer-android", optional = true } diff --git a/crates/connetto-client/src/bluetooth.rs b/crates/connetto-client/src/bluetooth.rs index abc80d79..9b124bbb 100644 --- a/crates/connetto-client/src/bluetooth.rs +++ b/crates/connetto-client/src/bluetooth.rs @@ -29,12 +29,16 @@ use crate::hotspot::{ #[cfg(target_os = "android")] mod android; -#[cfg(target_os = "android")] +#[cfg(target_os = "ios")] +mod apple; +#[cfg(any(target_os = "android", target_os = "ios"))] mod central; #[cfg(target_os = "android")] pub(crate) use android::AndroidBluetoothBackend; -#[cfg(target_os = "android")] +#[cfg(target_os = "ios")] +pub(crate) use apple::AppleBluetoothBackend; +#[cfg(any(target_os = "android", target_os = "ios"))] pub(crate) use central::BtleplugCentral; /// The pace of the machine's look while an exchange runs. @@ -127,6 +131,10 @@ pub enum BluetoothError { /// The platform's Bluetooth failed. #[error("the platform's Bluetooth failed: {0}")] Failed(String), + /// The beacon will not run while the standing holds, with the panel's + /// off reason (R76 decision 26). + #[error("{0}")] + BeaconOff(&'static str), } impl std::fmt::Display for PromptOutcome { @@ -178,10 +186,10 @@ pub enum BeaconState { /// An event the host's GATT service reports (R76). #[cfg_attr( - not(target_os = "android"), + not(any(target_os = "android", target_os = "ios")), expect( dead_code, - reason = "the platform backends construct these events, a non-Android build reads them through the tables only" + reason = "the platform backends construct these events, a non-Apple build reads them through the tables only" ) )] #[derive(Clone, Debug)] @@ -211,20 +219,22 @@ pub enum PeripheralEvent { /// An event the joiner's central reports (R76). #[cfg_attr( - not(any(test, target_os = "android")), + not(any(test, target_os = "android", target_os = "ios")), expect( dead_code, - reason = "btleplug's central runs on Android only until R76's slice 8, so elsewhere only the proofs construct these events" + reason = "btleplug's central runs on Android and iOS, so elsewhere only the proofs construct these events" ) )] #[derive(Clone, Debug)] pub enum CentralEvent { - /// A beacon was seen, with its service data and signal. + /// A beacon with connetto's UUID was seen, with its service data where + /// the advert carries it, and the signal. Seen { /// The host's key. host: HostId, - /// The beacon's service data. - service_data: Vec, + /// The beacon's service data, `None` where the advert carries the + /// UUID alone (R76 decision 25). + service_data: Option>, /// The signal's strength, where the platform reports one. rssi: Option, }, @@ -292,8 +302,10 @@ pub trait CentralBackend: Send + Sync { } /// The backends on a system with no Bluetooth. +#[cfg(not(target_os = "ios"))] pub struct UnsupportedBackend; +#[cfg(not(target_os = "ios"))] impl ReadinessBackend for UnsupportedBackend { fn state(&self) -> BluetoothState { BluetoothState::Unsupported @@ -312,6 +324,7 @@ impl ReadinessBackend for UnsupportedBackend { } } +#[cfg(not(target_os = "ios"))] impl PeripheralBackend for UnsupportedBackend { fn start(&self, _beacon: [u8; 9]) -> Result<(), BluetoothError> { Err(BluetoothError::Unsupported) @@ -332,6 +345,7 @@ impl PeripheralBackend for UnsupportedBackend { /// A system's readiness, its peripheral, and its central's starter where it /// runs one. +#[cfg(not(target_os = "ios"))] pub(crate) type Backends = ( Arc, Arc, @@ -339,6 +353,7 @@ pub(crate) type Backends = ( ); /// The backends on a system with no Bluetooth, which runs no central. +#[cfg(not(target_os = "ios"))] pub(crate) fn unsupported_backends() -> Backends { ( Arc::new(UnsupportedBackend) as Arc, @@ -416,10 +431,12 @@ enum HostBtState { /// The manual exchange's answer, by its kind. enum ManualReply { - /// The offer the fetch answers. - Fetch(Reply>), - /// The gateway the join answers. - Join(Reply>), + /// The host's identity and the offer the fetch answers (R76 decision + /// 25). + Fetch(Reply>), + /// The host's identity and the gateway the join answers (R76 decision + /// 25). + Join(Reply>), } /// The exchange's kind, what its offer goes to (R76). @@ -458,8 +475,8 @@ enum JoinerState { /// A seen host, for the panel and the tried rule (R76). struct HostRecord { - /// The beacon's prefix last seen. - prefix: [u8; 8], + /// The beacon's prefix last seen, where the beacon carries one. + prefix: Option<[u8; 8]>, /// When the beacon was last seen, on the monotonic clock. last_seen: tokio::time::Instant, /// The signal last seen. @@ -480,14 +497,14 @@ enum PromptPending { /// The host the fetch runs with. host: HostId, /// The answer, once the action finishes. - reply: Reply>, + reply: Reply>, }, /// The join's answer. Join { /// The host the join runs with. host: HostId, /// The answer, once the action finishes. - reply: Reply>, + reply: Reply>, }, } @@ -522,16 +539,21 @@ enum BeaconResolution { /// The prompt's outcome, `None` where the bound ran. outcome: Option, }, + /// The beacon will not run while the standing holds, with the panel's + /// off reason (R76 decision 26). + Off(&'static str), } /// A joined answer in flight, from the join machine (R76). struct JoinPending { /// The host the join runs with. host: HostId, + /// The host's proved identity, from the exchange (R76 decision 25). + identity: DeviceIdentity, /// The join machine's answer, once the network is up. reply: oneshot::Receiver>, /// The caller's answer, for the manual join. - caller: Option>>, + caller: Option>>, /// The bound the join gets. deadline: tokio::time::Instant, } @@ -557,6 +579,20 @@ pub(crate) type CentralStart = /// the platform's reason (R76 decision 22). pub(crate) type CentralStarter = Arc CentralStart + Send + Sync>; +/// btleplug's central as the machine's starter, which the machine runs once +/// Bluetooth reads Ready and again after a failed start (R76 decision 22). +#[cfg(any(target_os = "android", target_os = "ios"))] +pub(crate) fn btleplug_starter() -> CentralStarter { + Arc::new(|| -> CentralStart { + Box::pin(async { + BtleplugCentral::start() + .await + .map(|central| Arc::new(central) as Arc) + .map_err(|err| err.to_string()) + }) + }) +} + /// The joiner's central's standing, and its start's answer (R76 central /// table). enum CentralState { @@ -605,6 +641,9 @@ pub(crate) struct Machine { /// The calls waiting for the platform's action to start. action_waiters: Vec, beacon: watch::Sender, + /// The personal hotspot's landing bound, started when an exchange + /// hands the offer out (R76 decision 26). + handed_out: Arc, } impl Machine { @@ -627,6 +666,7 @@ impl Machine { serving: Arc Option + Send + Sync>, join: JoinSeam, emit: Arc, + handed_out: Arc, ) -> (Self, watch::Receiver) { let serving_present = (serving)().is_some(); let (beacon, beacon_rx) = watch::channel(BeaconState::Off { @@ -643,6 +683,7 @@ impl Machine { serving, join, emit, + handed_out, scan, autojoin, prompt, @@ -707,7 +748,7 @@ impl Machine { }, // The runner links the host's answer to the hotspot machine's, // so the machine does not take it on its own. - Command::Host(_) => {} + Command::Host(_) | Command::PersonalHost { .. } => {} Command::JoinNearby { host, reply } => { self.begin_manual(host, Kind::Join, ManualReply::Join(reply)); } @@ -1047,8 +1088,13 @@ impl Machine { } // The exchanges' rows, the outbound, the outcome, the bound. let ended = tick_host_exchanges(&self.peripheral, exchanges); - for (device, _outcome) in ended { + for (device, outcome) in ended { self.peripheral.disconnect(device); + // The exchange's offer goes out, so the personal + // hotspot's landing bound starts (R76 decision 26). + if outcome.is_ok() { + (self.handed_out)(); + } } } HostBtState::Off => {} @@ -1143,7 +1189,7 @@ fn tick_host_exchanges( // runs, so the exchange ends, with or without its offer. match outcome { Ok(identity) => tracing::debug!(?identity, "the exchange handed its offer"), - Err(err) => tracing::debug!(%err, "the exchange ended without its offer"), + Err(err) => tracing::debug!(?err, "the exchange ended without its offer"), } } } @@ -1261,44 +1307,50 @@ impl Machine { for event in events { let mut end = None; match event { - // A beacon with connetto's UUID is seen. + // A beacon with connetto's UUID is seen, keyed by its + // prefix where the advert carries service data and else by + // the host's address (R76 decision 25). CentralEvent::Seen { host, service_data, rssi, } => { - if let Some(beacon) = Beacon::from_service_data(&service_data) { + let prefix = match service_data.as_deref() { + // Service data that is not the beacon's is not a + // host. + Some(data) => { + Beacon::from_service_data(data).map(|beacon| Some(beacon.prefix)) + } + None => Some(None), + }; + if let Some(prefix) = prefix { match self.hosts.get_mut(&host) { None => { self.hosts.insert( host, HostRecord { - prefix: beacon.prefix, + prefix, last_seen: now, rssi, tried: false, }, ); - (self.emit)(ClientEvent::HostNearby { - host, - prefix: beacon.prefix, - rssi, - }); + (self.emit)(ClientEvent::HostNearby { host, prefix, rssi }); } - // A known prefix refreshes its signal. - Some(record) if record.prefix == beacon.prefix => { + // A known beacon refreshes its signal. + Some(record) if record.prefix == prefix => { record.last_seen = now; record.rssi = rssi; } - // A new prefix is a new host, untried again. + // A changed beacon is a new host, untried again. Some(record) => { - record.prefix = beacon.prefix; + record.prefix = prefix; record.last_seen = now; record.rssi = rssi; record.tried = false; (self.emit)(ClientEvent::HostNearby { host, - prefix: beacon.prefix, + prefix: record.prefix, rssi, }); } @@ -1514,11 +1566,12 @@ impl Machine { central.disconnect(host); } match outcome { - // The exchange yields the offer, so it goes to its kind. - Ok((_identity, frame)) => match kind { + // The exchange yields the offer, so it and its host's proved + // identity go to its kind (R76 decision 25). + Ok((identity, frame)) => match kind { Kind::Fetch => { if let Some(ManualReply::Fetch(reply)) = reply { - let _ = reply.send(Ok(hotspot_offer(&frame))); + let _ = reply.send(Ok((identity, hotspot_offer(&frame)))); } } Kind::Join => { @@ -1531,6 +1584,7 @@ impl Machine { }); self.pending_join.push(JoinPending { host, + identity, reply: join_rx, caller, deadline: now + JOIN_BOUND + MARGIN, @@ -1540,6 +1594,7 @@ impl Machine { // The host's chain is refused, the exchange times out, or // Bluetooth fails, so the host is marked tried. Err(err) => { + tracing::debug!(?err, "the join's exchange ended without its offer"); if let Some(record) = self.hosts.get_mut(&host) { record.tried = true; } @@ -1552,12 +1607,8 @@ impl Machine { BluetoothError::Exchange(err), ))); } - None => { - tracing::debug!( - %err, - "the autojoin's exchange ended without its offer" - ); - } + // An autojoin has no caller, its end logged above. + None => {} } } } @@ -1578,6 +1629,11 @@ impl Machine { { BeaconDecision::Resolve(BeaconResolution::Ok) } + // The beacon will not run while the device does not + // serve, so the caller's answer stands on its reason. + BluetoothState::Ready if (self.serving)().is_none() => BeaconDecision::Resolve( + BeaconResolution::Off(Self::beacon_reason(state, true, false)), + ), // The host's look starts the beacon, which the look // answers. BluetoothState::Ready => BeaconDecision::Wait, @@ -1643,6 +1699,7 @@ impl Machine { Ok(answer) => { let beacon = match resolution { BeaconResolution::Ok => Ok(()), + BeaconResolution::Off(reason) => Err(BluetoothError::BeaconOff(reason)), BeaconResolution::Refusal { state, outcome } => { Err(self.beacon_refusal(state, outcome)) } @@ -1718,7 +1775,7 @@ impl Machine { } if let Some(caller) = pending.caller { let _ = caller.send(match answer { - Ok(gateway) => Ok(gateway), + Ok(gateway) => Ok((pending.identity, gateway)), Err(err) => Err(JoinNearbyError::Join(err)), }); } @@ -1781,21 +1838,33 @@ pub(crate) enum Command { Enable(Reply>), /// Host the hotspot, the runner linking the hotspot machine's answer. Host(Reply>), + /// Host the personal hotspot under the caller's typed details, the + /// runner linking the hotspot machine's answer (R76 decision 26). + PersonalHost { + /// The network's name, as the caller types it. + ssid: String, + /// The network's passphrase, as the caller types it. + passphrase: String, + /// The answer, beside the beacon's outcome. + caller: Reply>, + }, /// Join the nearby host's hotspot through the exchange (R76 decision /// 19). JoinNearby { /// The host to join. host: HostId, - /// The answer, once the network is up. - reply: Reply>, + /// The answer, with the host's proved identity, once the network is + /// up. + reply: Reply>, }, /// Fetch the nearby host's offer through the exchange, without joining /// (R76 decision 19). Fetch { /// The host to fetch. host: HostId, - /// The answer, once the exchange yields the offer. - reply: Reply>, + /// The answer, with the host's proved identity, once the exchange + /// yields the offer. + reply: Reply>, }, /// The scan's standing, at run time (R76 decision 19). SetScan(bool), @@ -1885,6 +1954,21 @@ pub(crate) fn run( hotspot.handle(crate::hotspot::Command::Host(offer_tx)); bluetooth.hosted(offer_rx, caller); } + // The caller's answer stands linked to the beacon's, + // beside the hotspot machine's typed offer. + Command::PersonalHost { + ssid, + passphrase, + caller, + } => { + let (offer_tx, offer_rx) = oneshot::channel(); + hotspot.handle(crate::hotspot::Command::PersonalHost { + ssid, + passphrase, + reply: offer_tx, + }); + bluetooth.hosted(offer_rx, caller); + } command => bluetooth.handle(command), }, Next::Bluetooth(None) => bluetooth_closed = true, @@ -2324,6 +2408,8 @@ mod tests { starter: Arc, serving: Arc>>, events: Arc>>, + /// The landing's bound starts, counted (R76 decision 26). + handed: Arc>, beacon: watch::Receiver, } @@ -2339,11 +2425,19 @@ mod tests { impl Harness { /// A harness at `state`, scanning, prompting and serving `fp(1)`. fn new(state: BluetoothState) -> Self { - Self::with(state, quiet_node(), true, false, true, Some(fp(1))) + Self::with( + state, + quiet_node(), + true, + false, + true, + Some(fp(1)), + Arc::new(Wire::new()), + ) } - /// A harness over fresh fakes with the given standings and serving - /// fingerprint. + /// A harness over `wire`, fresh fakes and the given standings, + /// serving `serving`. fn with( state: BluetoothState, node: Node, @@ -2351,8 +2445,8 @@ mod tests { autojoin: bool, prompt: bool, serving: Option, + wire: Arc, ) -> Self { - let wire = Arc::new(Wire::new()); let readiness = Arc::new(FakeReadiness::new(state)); let peripheral = Arc::new(FakePeripheral::new(wire.clone())); let central = Arc::new(FakeCentral::new(wire)); @@ -2363,6 +2457,7 @@ mod tests { let prompt = Arc::new(Mutex::new(prompt)); let joins: Joins = Arc::new(Mutex::new(Vec::new())); let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let handed: Arc> = Arc::new(Mutex::new(0)); let (machine, beacon) = Machine::new( Arc::clone(&readiness) as Arc, Arc::clone(&peripheral) as Arc, @@ -2397,6 +2492,10 @@ mod tests { let events = Arc::clone(&events); move |event| events.lock().push(event) }), + Arc::new({ + let handed = Arc::clone(&handed); + move || *handed.lock() += 1 + }), ); Self { machine, @@ -2406,6 +2505,7 @@ mod tests { starter, serving, events, + handed, beacon, } } @@ -2432,12 +2532,22 @@ mod tests { fn host_seen(&self, host: HostId, host_fp: &Fingerprint, rssi: i16) { self.central.push(CentralEvent::Seen { host, - service_data: Beacon::of(host_fp).to_service_data().to_vec(), + service_data: Some(Beacon::of(host_fp).to_service_data().to_vec()), + rssi: Some(rssi), + }); + } + + /// A beacon with connetto's UUID and no service data, the iPhone's + /// (R76 decision 25). + fn host_seen_uuid_only(&self, host: HostId, rssi: i16) { + self.central.push(CentralEvent::Seen { + host, + service_data: None, rssi: Some(rssi), }); } - fn host_nearby(&self) -> Vec<(HostId, [u8; 8])> { + fn host_nearby(&self) -> Vec<(HostId, Option<[u8; 8]>)> { self.events .lock() .iter() @@ -2477,6 +2587,9 @@ mod tests { joiner_central: Arc, joins: Arc>>, host_fp: Fingerprint, + host_identity: DeviceIdentity, + /// The host's landing bound starts, counted (R76 decision 26). + handed: Arc>, } impl ExchangePair { @@ -2494,6 +2607,7 @@ mod tests { let h_peripheral = Arc::new(FakePeripheral::new(wire.clone())); let h_serving = Arc::new(Mutex::new(Some(host_fp))); let h_events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let handed: Arc> = Arc::new(Mutex::new(0)); let (host, _h_beacon) = Machine::new( h_readiness, h_peripheral.clone(), @@ -2511,6 +2625,10 @@ mod tests { let events = Arc::clone(&h_events); move |event| events.lock().push(event) }), + Arc::new({ + let handed = Arc::clone(&handed); + move || *handed.lock() += 1 + }), ); let j_readiness = Arc::new(FakeReadiness::new(BluetoothState::Ready)); let j_peripheral = Arc::new(FakePeripheral::new(dead_wire.clone())); @@ -2539,6 +2657,7 @@ mod tests { let events = Arc::clone(&j_events); move |event| events.lock().push(event) }), + Arc::new(|| ()), ); // The central stands, so the joiner's rows run against it. joiner.tick(None, false); @@ -2552,6 +2671,11 @@ mod tests { joiner_central: j_central, joins, host_fp, + host_identity: DeviceCertificate::parse(&d.host_leaf) + .expect("the deployment's leaf holds") + .identity() + .clone(), + handed, } } @@ -2651,6 +2775,163 @@ mod tests { assert_eq!(*h.peripheral.disconnects.lock(), vec![5]); } + /// The host's row `the exchange's answer is Ok | the offer was handed + /// out`, the landing's seam called once (R76 decision 26). + #[tokio::test] + async fn a_host_exchange_that_hands_its_offer_out_notifies_the_landing_bound() { + let mut pair = ExchangePair::new().await; + let device: HostId = 1; + let (join_tx, join_rx) = oneshot::channel(); + pair.joiner.handle(Command::JoinNearby { + host: device, + reply: join_tx, + }); + pair.joiner_central.push(CentralEvent::Connected { + host: device, + mtu: 517, + }); + pair.host_peripheral + .push(PeripheralEvent::Connected { device, mtu: 517 }); + let handed = Arc::clone(&pair.handed); + pair.pump_until(|| *handed.lock() == 1).await; + assert_eq!(*handed.lock(), 1); + drop(join_rx); + } + + /// The host's row `the exchange's answer is refused | the landing's + /// seam never called` (R76 decision 26). + #[tokio::test] + async fn a_refused_exchange_never_notifies_the_landing_bound() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(Some(&offer())); + h.peripheral.push(PeripheralEvent::Connected { + device: 7, + mtu: 517, + }); + h.tick(Some(&offer())); + // Its NotServing outcome ends it on the next look. + tokio::task::yield_now().await; + tokio::task::yield_now().await; + h.tick(Some(&offer())); + assert_eq!(*h.peripheral.disconnects.lock(), vec![7]); + assert_eq!(*h.handed.lock(), 0); + } + + /// The host's row `the exchange's answer is Ok | the offer was handed + /// out`, the joiner a separate machine over the shared wire (R76 + /// decision 26). + #[tokio::test] + async fn a_host_exchange_with_a_joiner_over_the_shared_wire_hands_its_offer_out() { + let d = Deployment::new(); + let wire = Arc::new(Wire::new()); + let host_node = serving_node(d.root.clone(), d.host_identity); + let joiner_node = serving_node(d.root.clone(), d.joiner_identity); + let host_fp = Fingerprint::of(&d.host_leaf); + let joiner_fp = Fingerprint::of(&d.joiner_leaf); + let mut h = Harness::with( + BluetoothState::Ready, + host_node, + false, + false, + true, + Some(host_fp), + wire.clone(), + ); + // The machine plays no joiner, so the wire's joiner side stands to + // the joiner's central alone. + h.machine.starter = None; + h.tick(Some(&offer())); + let joiner_central = Arc::new(FakeCentral::new(wire.clone())); + let (mut joiner, _joiner_beacon) = Machine::new( + Arc::new(FakeReadiness::new(BluetoothState::Ready)), + Arc::new(FakePeripheral::new(Arc::new(Wire::new()))), + Some(instant_starter(Arc::clone(&joiner_central))), + joiner_node, + true, + false, + true, + Arc::new(move || Some(joiner_fp)), + Arc::new(|_offer, _reply| {}), + Arc::new(|_event| {}), + Arc::new(|| ()), + ); + joiner.tick(None, false); + tokio::task::yield_now().await; + tokio::task::yield_now().await; + joiner.tick(None, false); + let (reply, _reply_rx) = oneshot::channel(); + joiner.handle(Command::JoinNearby { host: 7, reply }); + h.peripheral.push(PeripheralEvent::Connected { + device: 7, + mtu: 517, + }); + joiner_central.push(CentralEvent::Connected { host: 7, mtu: 517 }); + let mut p = 0; + loop { + if *h.handed.lock() == 1 { + break; + } + p += 1; + assert!(p < 2000, "the exchange did not complete in time"); + h.tick(Some(&offer())); + joiner.tick(None, false); + tokio::time::sleep(Duration::from_millis(1)).await; + } + assert_eq!(*h.peripheral.disconnects.lock(), vec![7]); + } + + /// The hosted's row `the offer is in hand and the device does not + /// serve | the beacon stands off, the caller's answer stands on its + /// reason` (R76 decision 26). + #[tokio::test] + async fn a_hosted_call_on_a_not_serving_device_answers_its_beacon_off() { + let mut h = Harness::with( + BluetoothState::Ready, + quiet_node(), + true, + false, + true, + None, + Arc::new(Wire::new()), + ); + let (offer_tx, offer_rx) = oneshot::channel(); + let (reply_tx, mut reply_rx) = oneshot::channel(); + h.machine.hosted(offer_rx, reply_tx); + let _ = offer_tx.send(Ok(offer())); + h.tick(Some(&offer())); + let Hosted { offer, beacon } = reply_rx + .try_recv() + .expect("the caller is answered") + .expect("the hotspot started"); + assert_eq!( + offer, + HotspotOffer::new("hotspot", "secret", HotspotSecurity::Wpa2, Some(54321)) + ); + assert!(matches!( + beacon, + Err(BluetoothError::BeaconOff(reason)) + if reason == "the device does not serve its peer" + )); + } + + /// The machine's row `the host's answer stands linked by the runner`, + /// so the machine takes it on no one's behalf and answers no one. + #[test] + fn the_machine_takes_no_host_answer_on_its_own() { + let mut h = Harness::new(BluetoothState::Ready); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::Host(reply)); + let (personal, mut personal_answer) = oneshot::channel(); + h.machine.handle(Command::PersonalHost { + ssid: "iphone".into(), + passphrase: "the passphrase".into(), + caller: personal, + }); + assert!(answer.try_recv().is_err()); + assert!(personal_answer.try_recv().is_err()); + assert!(h.events.lock().is_empty()); + } + #[tokio::test] async fn a_refused_chain_ends_its_exchange_without_an_offer() { let mut h = Harness::new(BluetoothState::Ready); @@ -2672,7 +2953,15 @@ mod tests { async fn a_stalled_exchange_ends_at_its_bound() { let d = Deployment::new(); let node = serving_node(d.root.clone(), d.host_identity); - let mut h = Harness::with(BluetoothState::Ready, node, true, false, true, Some(fp(1))); + let mut h = Harness::with( + BluetoothState::Ready, + node, + true, + false, + true, + Some(fp(1)), + Arc::new(Wire::new()), + ); h.tick(Some(&offer())); h.peripheral.push(PeripheralEvent::Connected { device: 7, @@ -2910,7 +3199,7 @@ mod tests { h.settle().await; h.host_seen(1, &host_fp, -50); h.tick(None); - assert_eq!(h.host_nearby(), vec![(1, host_fp.prefix())]); + assert_eq!(h.host_nearby(), vec![(1, Some(host_fp.prefix()))]); h.machine.handle(Command::SetScan(false)); h.tick(None); assert!(!h.central.started()); @@ -2924,11 +3213,57 @@ mod tests { h.settle().await; h.host_seen(1, &host_fp, -50); h.tick(None); - assert_eq!(h.host_nearby(), vec![(1, host_fp.prefix())]); + assert_eq!(h.host_nearby(), vec![(1, Some(host_fp.prefix()))]); // A known prefix refreshes its signal, with no new event. h.host_seen(1, &host_fp, -60); h.tick(None); - assert_eq!(h.host_nearby(), vec![(1, host_fp.prefix())]); + assert_eq!(h.host_nearby(), vec![(1, Some(host_fp.prefix()))]); + } + + /// The joiner row `a beacon with connetto's UUID is seen`, else by its + /// Bluetooth address where the beacon carries no service data (R76 + /// decision 25), and the same seen without data again refreshes it. + #[tokio::test] + async fn a_beacon_without_service_data_is_a_host_by_its_address() { + let mut h = Harness::new(BluetoothState::Ready); + h.settle().await; + h.host_seen_uuid_only(1, -50); + h.tick(None); + assert_eq!(h.host_nearby(), vec![(1, None)]); + // The same seen again refreshes its signal, with no new event. + h.host_seen_uuid_only(1, -60); + h.tick(None); + assert_eq!(h.host_nearby(), vec![(1, None)]); + } + + /// The joiner row `a new prefix is a new host`, where a beacon without + /// service data gains one, and a malformed service data is not a host. + #[tokio::test] + async fn a_beacon_that_gains_a_prefix_is_a_new_host() { + let mut h = Harness::new(BluetoothState::Ready); + let host_fp = fp(9); + h.settle().await; + h.host_seen_uuid_only(1, -50); + h.tick(None); + assert_eq!(h.host_nearby(), vec![(1, None)]); + // The beacon gains service data, so it is a new host, untried again. + h.host_seen(1, &host_fp, -60); + h.tick(None); + assert_eq!( + h.host_nearby(), + vec![(1, None), (1, Some(host_fp.prefix()))] + ); + // Service data that is not a beacon is not a host. + h.central.push(CentralEvent::Seen { + host: 2, + service_data: Some(vec![0; 8]), + rssi: Some(-40), + }); + h.tick(None); + assert_eq!( + h.host_nearby(), + vec![(1, None), (1, Some(host_fp.prefix()))] + ); } #[tokio::test(start_paused = true)] @@ -2953,6 +3288,7 @@ mod tests { true, true, Some(fp(1)), + Arc::new(Wire::new()), ); let weak = fp(10); let strong = fp(11); @@ -3076,6 +3412,32 @@ mod tests { assert!(h.strongest_untried().is_none()); } + /// The joiner's row `the autojoin's exchange ends without an offer | + /// the host stands tried, and the answer goes to no one` (R76 + /// decision 19). + #[tokio::test] + async fn a_stalled_autojoin_ends_without_an_answer() { + let mut h = Harness::with( + BluetoothState::Ready, + quiet_node(), + true, + true, + true, + Some(fp(1)), + Arc::new(Wire::new()), + ); + h.settle().await; + h.host_seen(7, &fp(7), -40); + h.tick(None); + assert_eq!(*h.central.connects.lock(), vec![7]); + // The host drops the connection before the stream runs, so the + // exchange ends by its error, with no caller to answer. + h.central.push(CentralEvent::Disconnected { host: 7 }); + h.tick(None); + assert_eq!(*h.central.disconnects.lock(), vec![7]); + assert!(h.strongest_untried().is_none()); + } + #[tokio::test] async fn a_declined_join_marks_its_host_tried() { let mut pair = ExchangePair::new().await; @@ -3083,7 +3445,7 @@ mod tests { // The host's beacon is seen, so it is tracked and untried. pair.joiner_central.push(CentralEvent::Seen { host: device, - service_data: Beacon::of(&pair.host_fp).to_service_data().to_vec(), + service_data: Some(Beacon::of(&pair.host_fp).to_service_data().to_vec()), rssi: Some(-40), }); pair.joiner.tick(None, false); @@ -3219,17 +3581,60 @@ mod tests { let (offer, join_seam) = pair.joins.lock().remove(0); assert_eq!(offer.ssid, "hotspot"); assert_eq!(offer.passphrase, "secret"); - // The join answers its gateway, so the caller gets it. + // The join answers its gateway and its host's proved identity, so + // the caller gets them. let _ = join_seam.send(Ok("192.168.43.1".parse().unwrap())); pair.joiner.tick(None, false); let gateway: IpAddr = "192.168.43.1".parse().unwrap(); let answer = join_rx.try_recv(); - let Ok(Ok(answered)) = answer else { + let Ok(Ok((identity, answered))) = answer else { panic!("the join answers its gateway, got {answer:?}") }; + assert_eq!(identity, pair.host_identity); assert_eq!(answered, gateway); } + /// The joiner row `the exchange's answer is Ok, with the host's + /// identity and the offer it carried`, for the fetch (R76 decision 25). + #[tokio::test] + async fn a_fetched_offer_reaches_its_caller_with_its_proved_identity() { + let mut pair = ExchangePair::new().await; + let device: HostId = 1; + let (fetch_tx, mut fetch_rx) = oneshot::channel(); + pair.joiner.handle(Command::Fetch { + host: device, + reply: fetch_tx, + }); + pair.joiner_central.push(CentralEvent::Connected { + host: device, + mtu: 517, + }); + pair.host_peripheral + .push(PeripheralEvent::Connected { device, mtu: 517 }); + let host_peripheral = Arc::clone(&pair.host_peripheral); + pair.pump_until(|| host_peripheral.disconnects.lock().contains(&device)) + .await; + // The joiner's stream ends by the host's EOF, so the exchange's + // answer lands on its next looks. + let mut p = 0; + let answer = loop { + if let Ok(answer) = fetch_rx.try_recv() { + break answer; + } + p += 1; + assert!(p < 2000, "the fetch did not answer in time"); + pair.host.tick(Some(&offer()), false); + pair.joiner.tick(None, false); + tokio::time::sleep(Duration::from_millis(1)).await; + }; + let Ok((identity, offer)) = answer else { + panic!("the fetch answers its offer, got the channel closed"); + }; + assert_eq!(identity, pair.host_identity); + assert_eq!(offer.ssid, "hotspot"); + assert_eq!(offer.passphrase, "secret"); + } + // --- The unified runner --- /// A hotspot backend whose host stands Started, so the runner's host @@ -3271,6 +3676,18 @@ mod tests { fn join_status(&self) -> crate::hotspot::JoinStatus { crate::hotspot::JoinStatus::Pending } + + fn request_personal_host( + &self, + _ssid: &str, + _passphrase: &str, + ) -> Result<(), HotspotError> { + Ok(()) + } + + fn personal_landed(&self) -> bool { + false + } } #[tokio::test(start_paused = true)] @@ -3297,6 +3714,7 @@ mod tests { let events = Arc::clone(&h_events); move |event| events.lock().push(event) }), + Arc::new(|| ()), ); let hotspot_backend: Arc = Arc::new(FakeHotspotBackend::new()); let hotspot_events: Arc>> = Arc::new(Mutex::new(Vec::new())); @@ -3338,6 +3756,88 @@ mod tests { .expect("the runner task is sound"); } + /// The runner's row `host_personal_hotspot | the typed request stands | + /// the caller's answer stands linked to the beacon's` (R76 decision + /// 26). + #[tokio::test(start_paused = true)] + async fn the_runner_answers_a_personal_host_and_ends_with_its_channel() { + let wire = Arc::new(Wire::new()); + let h_readiness = Arc::new(FakeReadiness::new(BluetoothState::Ready)); + let h_peripheral = Arc::new(FakePeripheral::new(wire.clone())); + let h_central = Arc::new(FakeCentral::new(wire)); + let h_serving = Arc::new(Mutex::new(Some(fp(1)))); + let h_events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (bt_machine, _bt_beacon) = Machine::new( + h_readiness, + h_peripheral, + Some(instant_starter(h_central)), + quiet_node(), + false, + false, + true, + Arc::new({ + let serving = Arc::clone(&h_serving); + move || *serving.lock() + }), + Arc::new(move |_offer, _reply| {}), + Arc::new({ + let events = Arc::clone(&h_events); + move |event| events.lock().push(event) + }), + Arc::new(|| ()), + ); + let hotspot_backend: Arc = Arc::new(FakeHotspotBackend::new()); + let hotspot_events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let hotspot_machine = crate::hotspot::Machine::new( + Arc::clone(&hotspot_backend) as Arc, + false, + Arc::new(|| Some(54321)), + Arc::new(|_subnet| {}), + Arc::new(|_addr, _answer| {}), + Arc::new({ + let events = Arc::clone(&hotspot_events); + move |event| events.lock().push(event) + }), + ); + let (hotspot_tx, hotspot_rx) = mpsc::unbounded_channel(); + let (bt_tx, bt_rx) = mpsc::unbounded_channel(); + let runner = run(hotspot_machine, hotspot_rx, bt_machine, bt_rx); + let runner = tokio::spawn(async move { + tokio::pin!(runner); + runner.await; + }); + let (personal_tx, personal_rx) = oneshot::channel(); + bt_tx + .send(Command::PersonalHost { + ssid: "luca's iphone".into(), + passphrase: "the passphrase".into(), + caller: personal_tx, + }) + .expect("the channel is open"); + let hosted = tokio::time::timeout(Duration::from_secs(5), personal_rx) + .await + .expect("the personal host's answer arrives") + .expect("the channel stays open"); + let hosted = hosted.expect("the personal hotspot starts"); + assert_eq!( + hosted.offer, + HotspotOffer::new( + "luca's iphone", + "the passphrase", + HotspotSecurity::Wpa2, + Some(54321), + ) + ); + assert!(hosted.beacon.is_ok()); + // Both channels close, so the runner ends with them. + drop(hotspot_tx); + drop(bt_tx); + tokio::time::timeout(Duration::from_secs(5), runner) + .await + .expect("the runner ends") + .expect("the runner task is sound"); + } + /// A dropped runner clears the dial bind beside releasing the hotspot, /// so a dial into the joined subnet no longer asks for the released /// network. @@ -3355,6 +3855,7 @@ mod tests { Arc::new(|| None), Arc::new(move |_offer, _reply| {}), Arc::new(|_event| {}), + Arc::new(|| ()), ); let binds = Arc::new(Mutex::new(Vec::new())); let hotspot_machine = crate::hotspot::Machine::new( diff --git a/crates/connetto-client/src/bluetooth/apple.rs b/crates/connetto-client/src/bluetooth/apple.rs new file mode 100644 index 00000000..7d379dac --- /dev/null +++ b/crates/connetto-client/src/bluetooth/apple.rs @@ -0,0 +1,116 @@ +//! The Apple Bluetooth backend, over the process-wide peer bridge (R76). +//! +//! The readiness stands on the platform's `CBPeripheralManager`, the +//! service on its GATT service and advert, and the joiner's central stays +//! btleplug's, over the same platform. + +use connetto_peer_apple::bluetooth as apple; + +use super::{ + BluetoothError, BluetoothState, PeripheralBackend, PeripheralEvent, PromptOutcome, + ReadinessBackend, +}; + +/// The Apple Bluetooth backend, over `connetto-peer-apple` (R76). +pub(crate) struct AppleBluetoothBackend; + +impl AppleBluetoothBackend { + /// The backend's new. + pub(crate) fn new() -> Self { + Self + } +} + +/// The permission a `NotPermitted` standing names. +const BLUETOOTH_PERMISSION: &str = "Bluetooth"; + +/// Map the platform's standing to the client's. +fn state(state: apple::State) -> BluetoothState { + match state { + apple::State::Ready => BluetoothState::Ready, + apple::State::Off => BluetoothState::Off, + apple::State::NotPermitted => BluetoothState::NotPermitted, + apple::State::Unsupported => BluetoothState::Unsupported, + } +} + +/// Map the platform's prompt outcome to the client's. +fn outcome(outcome: apple::PromptOutcome) -> PromptOutcome { + match outcome { + apple::PromptOutcome::NotAsked => PromptOutcome::NotAsked, + apple::PromptOutcome::Declined => PromptOutcome::Declined, + } +} + +/// The platform's service event as the client's. +fn event(event: apple::Event) -> PeripheralEvent { + match &event { + apple::Event::Chunk { device, bytes } => { + tracing::debug!(device, len = bytes.len(), "the service took a chunk"); + } + other => tracing::debug!(event = ?other, "the service reported"), + } + match event { + apple::Event::Connected { device, mtu } => PeripheralEvent::Connected { device, mtu }, + apple::Event::Chunk { device, bytes } => PeripheralEvent::Chunk { device, bytes }, + apple::Event::Disconnected { device } => PeripheralEvent::Disconnected { device }, + apple::Event::Failed(reason) => PeripheralEvent::Failed(reason), + } +} + +/// The platform's error as the client's `Failed`. +fn failed(error: &connetto_peer_apple::Error) -> BluetoothError { + BluetoothError::Failed(error.to_string()) +} + +impl ReadinessBackend for AppleBluetoothBackend { + fn state(&self) -> BluetoothState { + state(apple::state()) + } + + fn missing_permissions(&self) -> Vec { + match apple::state() { + apple::State::NotPermitted => vec![BLUETOOTH_PERMISSION.to_owned()], + _ => Vec::new(), + } + } + + fn prompt(&self) -> Result<(), BluetoothError> { + apple::prompt().map_err(|error| failed(&error)) + } + + fn prompt_outcome(&self) -> Option { + apple::prompt_outcome().map(outcome) + } +} + +impl PeripheralBackend for AppleBluetoothBackend { + fn start(&self, _beacon: [u8; 9]) -> Result<(), BluetoothError> { + // The advert carries the service's UUID alone, so the beacon's bytes + // stand out of it (R76 decision 25). + apple::start().map_err(|error| failed(&error)) + } + + fn stop(&self) { + let _ = apple::stop(); + } + + fn poll(&self) -> Vec { + apple::poll().into_iter().map(event).collect() + } + + fn notify(&self, device: u64, bytes: &[u8]) -> Result<(), BluetoothError> { + let sent = apple::notify(device, bytes).map_err(|error| failed(&error)); + tracing::debug!( + device, + len = bytes.len(), + ok = sent.is_ok(), + "the service notified" + ); + sent + } + + fn disconnect(&self, device: u64) { + apple::disconnect(device); + } +} diff --git a/crates/connetto-client/src/bluetooth/central.rs b/crates/connetto-client/src/bluetooth/central.rs index 1733ffbd..b65d1c74 100644 --- a/crates/connetto-client/src/bluetooth/central.rs +++ b/crates/connetto-client/src/bluetooth/central.rs @@ -43,9 +43,11 @@ type Queue = Arc>>; #[derive(Debug, thiserror::Error)] pub(crate) enum CentralError { /// The platform's preparation of btleplug's thread failed. + #[cfg(target_os = "android")] #[error("btleplug's thread could not be prepared: {0}")] Prepare(String), /// The virtual machine refused a call while the thread was prepared. + #[cfg(target_os = "android")] #[error("the virtual machine refused: {0}")] Jni(#[from] connetto_peer_android::jni::errors::Error), /// btleplug refused a call. @@ -179,6 +181,17 @@ fn prepare_thread() -> Result<(), CentralError> { }) } +/// The thread needs no preparation off Android, where btleplug is plain +/// `CoreBluetooth` (R76). +#[cfg(not(target_os = "android"))] +#[expect( + clippy::unnecessary_wraps, + reason = "Android's twin can fail, and the one caller answers both through ?" +)] +fn prepare_thread() -> Result<(), CentralError> { + Ok(()) +} + /// The thread's loop: the machine's commands against btleplug, and /// btleplug's events back to the machine, until the client drops its end. async fn drive(adapter: Adapter, mut commands: mpsc::UnboundedReceiver, queue: Queue) { @@ -197,7 +210,15 @@ async fn drive(adapter: Adapter, mut commands: mpsc::UnboundedReceiver, let Some(command) = command else { break }; match command { Command::StartScan => { - if let Err(err) = adapter.start_scan(ScanFilter::default()).await { + // iOS scans only connetto's service (R76 decision + // 25), Android scans wide. + #[cfg(target_os = "ios")] + let filter = ScanFilter { + services: vec![Uuid::from_bytes(SERVICE_UUID)], + }; + #[cfg(not(target_os = "ios"))] + let filter = ScanFilter::default(); + if let Err(err) = adapter.start_scan(filter).await { tracing::warn!(%err, "the scan did not start"); } } @@ -238,12 +259,16 @@ async fn drive(adapter: Adapter, mut commands: mpsc::UnboundedReceiver, let Some(event) = event else { break }; match event { BtEvent::ServiceDataAdvertisement { id, service_data } => { - let Some(data) = service_data.get(&Uuid::from_bytes(SERVICE_UUID)) else { - continue; - }; - let rssi = rssi_of(&adapter, &id).await; - let host = hosts.host(id); - push(&queue, CentralEvent::Seen { host, service_data: data.clone(), rssi }); + if let Some(data) = service_data.get(&Uuid::from_bytes(SERVICE_UUID)) { + report_seen(&adapter, &mut hosts, &queue, id, Some(data.clone())).await; + } + } + // The advert carries connetto's UUID alone, the + // iPhone's beacon (R76 decision 25). + BtEvent::ServicesAdvertisement { id, services } => { + if services.contains(&Uuid::from_bytes(SERVICE_UUID)) { + report_seen(&adapter, &mut hosts, &queue, id, None).await; + } } BtEvent::DeviceDisconnected(id) => { if let Some(host) = hosts.known(&id) && links.remove(&host).is_some() { @@ -258,6 +283,27 @@ async fn drive(adapter: Adapter, mut commands: mpsc::UnboundedReceiver, let _ = adapter.stop_scan().await; } +/// Report a peripheral advertising connetto's service as seen, with its +/// signal where the platform reports one. +async fn report_seen( + adapter: &Adapter, + hosts: &mut Hosts, + queue: &Queue, + id: PeripheralId, + service_data: Option>, +) { + let rssi = rssi_of(adapter, &id).await; + let host = hosts.host(id); + push( + queue, + CentralEvent::Seen { + host, + service_data, + rssi, + }, + ); +} + /// The signal of the peripheral's last advertisement, where the platform /// reports one. async fn rssi_of(adapter: &Adapter, id: &PeripheralId) -> Option { diff --git a/crates/connetto-client/src/builder/native.rs b/crates/connetto-client/src/builder/native.rs index 94b9f62c..5235e217 100644 --- a/crates/connetto-client/src/builder/native.rs +++ b/crates/connetto-client/src/builder/native.rs @@ -864,7 +864,11 @@ where None => Arc::new(crate::hotspot::UnsupportedBackend), } } - #[cfg(not(target_os = "android"))] + #[cfg(target_os = "ios")] + { + Arc::new(crate::hotspot::AppleHotspotBackend::new()) + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] { Arc::new(crate::hotspot::UnsupportedBackend) } @@ -927,36 +931,31 @@ where Some(java) => { let backend = Arc::new(crate::bluetooth::AndroidBluetoothBackend::new(java)); - // btleplug's central, started once - // Bluetooth reads Ready and retried where - // its start fails (R76 decision 22). - let bt_starter: crate::bluetooth::CentralStarter = - Arc::new(move || -> crate::bluetooth::CentralStart { - Box::pin(async move { - crate::bluetooth::BtleplugCentral::start() - .await - .map(|central| { - Arc::new(central) - as Arc - }) - .map_err(|err| err.to_string()) - }) - }); ( Arc::clone(&backend) as Arc, Arc::clone(&backend) as Arc, - Some(bt_starter), + Some(crate::bluetooth::btleplug_starter()), ) } None => crate::bluetooth::unsupported_backends(), }; - #[cfg(not(target_os = "android"))] + #[cfg(target_os = "ios")] + let (bt_readiness, bt_peripheral, bt_starter) = { + let backend = Arc::new(crate::bluetooth::AppleBluetoothBackend::new()); + ( + Arc::clone(&backend) as Arc, + Arc::clone(&backend) as Arc, + Some(crate::bluetooth::btleplug_starter()), + ) + }; + #[cfg(not(any(target_os = "android", target_os = "ios")))] let (bt_readiness, bt_peripheral, bt_starter) = crate::bluetooth::unsupported_backends(); let bt_state = Arc::clone(&bt_readiness); let serving_handle = handle.clone(); let join_handle = handle.clone(); + let handed_handle = handle.clone(); let beacon_node = peer_node.clone(); let bt_machine = crate::bluetooth::Machine::new( bt_readiness, @@ -980,6 +979,10 @@ where Arc::new(move |event: ClientEvent| { let _ = event_sender_bt.send(event); }), + Arc::new({ + let handle = handed_handle; + move || handle.personal_offer_handed_out() + }), ); ( crate::bluetooth::run(machine, hotspot_rx, bt_machine.0, bluetooth_rx), @@ -1239,6 +1242,45 @@ where } } + /// Host this device's personal hotspot under the caller's typed + /// details, the beacon's outcome beside the offer, within the + /// machines' bounds (R76 decision 26). + /// + /// # Errors + /// + /// [`HotspotError::Unsupported`] on a system without a personal + /// hotspot or for a build without a device identity, + /// [`HotspotError::Incompatible`] on a model that cannot host, and + /// [`HotspotError::TimedOut`] when the answer does not stand within + /// its bound. The beacon's refusal stands beside the offer on the + /// success. + #[cfg(feature = "peer")] + pub async fn host_personal_hotspot( + &self, + ssid: &str, + passphrase: &str, + ) -> Result { + match &self.device { + Some(device) => device.host_personal_hotspot(ssid, passphrase).await, + None => Err(HotspotError::Unsupported), + } + } + + /// The personal hotspot's network name, where the platform answers one + /// (R76 decision 26). + #[cfg(feature = "peer")] + #[must_use] + pub fn personal_hotspot_name(&self) -> Option { + #[cfg(target_os = "ios")] + { + connetto_peer_apple::device_name() + } + #[cfg(not(target_os = "ios"))] + { + None + } + } + /// Stop hosting, or cancel the pending request (R76). /// /// A no-op on a system without a hotspot or for a build without a device @@ -1331,8 +1373,9 @@ where } } - /// Join the nearby host's hotspot through the exchange, within the - /// machines' bounds (R76 decision 19). + /// Join the nearby host's hotspot through the exchange, answered with + /// the host's proved identity and the network's gateway, within the + /// machines' bounds (R76 decisions 19 and 25). /// /// # Errors /// @@ -1343,7 +1386,10 @@ where pub async fn join_nearby( &self, host: &crate::bluetooth::HostId, - ) -> Result { + ) -> Result< + (connetto_core::device_cert::DeviceIdentity, std::net::IpAddr), + crate::bluetooth::JoinNearbyError, + > { match &self.device { Some(device) => device.join_nearby(host).await, None => Err(crate::bluetooth::JoinNearbyError::Bluetooth( @@ -1352,8 +1398,9 @@ where } } - /// Fetch the nearby host's offer through the exchange, within the - /// machine's bound (R76 decision 19). + /// Fetch the nearby host's offer through the exchange, answered with + /// the host's proved identity, within the machine's bound (R76 + /// decisions 19 and 25). /// /// # Errors /// @@ -1365,7 +1412,10 @@ where pub async fn fetch_offer( &self, host: &crate::bluetooth::HostId, - ) -> Result { + ) -> Result< + (connetto_core::device_cert::DeviceIdentity, HotspotOffer), + crate::bluetooth::BluetoothError, + > { match &self.device { Some(device) => device.fetch_offer(host).await, None => Err(crate::bluetooth::BluetoothError::Unsupported), @@ -1845,6 +1895,15 @@ mod tests { ), "the build has no device identity to host" ); + assert!( + matches!( + client + .host_personal_hotspot("iphone", "the passphrase") + .await, + Err(crate::hotspot::HotspotError::Unsupported) + ), + "the build has no device identity to host personally" + ); assert!( matches!( client.join_hotspot(&offer).await, @@ -1876,6 +1935,10 @@ mod tests { "the build has no device identity to join nearby" ); assert_eq!(client.bluetooth_state(), BluetoothState::Unsupported); + assert!( + client.personal_hotspot_name().is_none(), + "no personal hotspot name without the device" + ); assert_eq!( client.beacon_state(), BeaconState::Off { @@ -1929,7 +1992,16 @@ mod tests { let (hotspot_tx, mut hotspot_rx) = mpsc::unbounded_channel(); let (bluetooth_tx, mut bluetooth_rx) = mpsc::unbounded_channel(); let (note_tx, mut note_rx) = mpsc::channel(8); + // The identity the nearby host proves in the exchange. + let host = connetto_core::device_cert::DeviceIdentity::new( + connetto_core::device_cert::DeploymentId::from_uuid(uuid::Uuid::from_u128(7)), + "host", + connetto_core::device_cert::KeyId::from_bytes([7; 32]), + ) + .expect("an identity holds"); + let machine_host = host.clone(); let machine = tokio::spawn(async move { + let host = machine_host; let gateway: std::net::IpAddr = "192.168.4.1".parse().expect("a private gateway"); let offer = crate::hotspot::HotspotOffer::new( "camp", @@ -1952,6 +2024,17 @@ mod tests { Some(crate::hotspot::Command::Leave) => { let _ = note_tx.send("leave").await; } + // The personal request the runner links stands + // unanswered here, and the hand out notes its + // arrival. + Some(command) => { + if matches!( + command, + crate::hotspot::Command::PersonalOfferHandedOut + ) { + let _ = note_tx.send("handed out").await; + } + } None => break, }, command = bluetooth_rx.recv() => match command { @@ -1959,10 +2042,10 @@ mod tests { let _ = reply.send(Ok(())); } Some(crate::bluetooth::Command::JoinNearby { reply, .. }) => { - let _ = reply.send(Ok(gateway)); + let _ = reply.send(Ok((host.clone(), gateway))); } Some(crate::bluetooth::Command::Fetch { reply, .. }) => { - let _ = reply.send(Ok(offer.clone())); + let _ = reply.send(Ok((host.clone(), offer.clone()))); } Some(crate::bluetooth::Command::Host(reply)) => { let _ = reply.send(Ok(crate::bluetooth::Hosted { @@ -1985,6 +2068,8 @@ mod tests { .send(if on { "prompt true" } else { "prompt false" }) .await; } + // The personal hotspot is the iPhone's, which this test does not host. + Some(_) => {} None => break, }, } @@ -2077,19 +2162,44 @@ mod tests { .enable_bluetooth() .await .expect("the machine answers"); - let gateway = client.join_nearby(&9).await.expect("the machine answers"); + let (identity, gateway) = client.join_nearby(&9).await.expect("the machine answers"); + assert_eq!( + identity, host, + "the host's proven identity reaches the caller" + ); assert_eq!( gateway, "192.168.4.1" .parse::() .expect("a private gateway") ); - let fetched = client.fetch_offer(&9).await.expect("the machine answers"); + let (identity, fetched) = client.fetch_offer(&9).await.expect("the machine answers"); + assert_eq!( + identity, host, + "the host's proven identity reaches the caller" + ); assert_eq!( fetched.ssid, "camp", "the machine's offer reaches the caller" ); + // The personal host's call reaches the Bluetooth machine, whose + // dropped reply fails it, and the hand out notes its arrival on + // the hotspot's side. + let error = client + .host_personal_hotspot("iphone", "the passphrase") + .await + .expect_err("the machine drops the personal host's reply"); + assert!( + matches!(error, crate::hotspot::HotspotError::Failed), + "a machine that drops the reply fails the call, got {error:?}" + ); + client + .device + .as_ref() + .expect("the device stands") + .personal_offer_handed_out(); + // The standings read the machines', and the cancels and the run-time // standings reach them. assert_eq!( @@ -2110,7 +2220,7 @@ mod tests { // The standings reach their machines, from the two machines' channels, // so the order the notes come back in is not the order sent. let mut noted: Vec<&str> = Vec::new(); - for _ in 0..5 { + for _ in 0..6 { let got = tokio::time::timeout(Duration::from_secs(10), note_rx.recv()) .await .expect("the machine notes the standing within its bound") @@ -2122,6 +2232,7 @@ mod tests { noted, [ "autojoin false", + "handed out", "leave", "prompt true", "scan true", diff --git a/crates/connetto-client/src/enrolment/task.rs b/crates/connetto-client/src/enrolment/task.rs index b1e0e6a1..3eaa3e63 100644 --- a/crates/connetto-client/src/enrolment/task.rs +++ b/crates/connetto-client/src/enrolment/task.rs @@ -472,6 +472,39 @@ impl EnrolHandle { } } + /// Host this device's personal hotspot under its typed details, its + /// beacon's outcome beside the offer, within the machines' bounds (R76 + /// decision 26). + #[cfg(feature = "peer")] + pub(crate) async fn host_personal_hotspot( + &self, + ssid: &str, + passphrase: &str, + ) -> Result { + let (reply, answer) = oneshot::channel(); + self.bluetooth + .send(crate::bluetooth::Command::PersonalHost { + ssid: ssid.to_owned(), + passphrase: passphrase.to_owned(), + caller: reply, + }) + .map_err(|_| HotspotError::Failed)?; + match tokio::time::timeout(HOST_BOUND + PROMPT_BOUND + MARGIN, answer).await { + Ok(Ok(hosted)) => hosted, + Ok(Err(_)) => Err(HotspotError::Failed), + Err(_) => Err(HotspotError::TimedOut), + } + } + + /// The personal hotspot's landing bound, started when an exchange + /// hands the offer out (R76 decision 26). + #[cfg(feature = "peer")] + pub(crate) fn personal_offer_handed_out(&self) { + let _ = self + .hotspot + .send(crate::hotspot::Command::PersonalOfferHandedOut); + } + /// Stop hosting, or cancel the pending request (R76). #[cfg(feature = "peer")] pub(crate) fn stop_hotspot(&self) { @@ -520,13 +553,14 @@ impl EnrolHandle { } } - /// Join the nearby host's hotspot through the exchange, within the - /// machines' bounds (R76 decision 19). + /// Join the nearby host's hotspot through the exchange, answered with + /// its proved identity and the network's gateway, within the machines' + /// bounds (R76 decisions 19 and 25). #[cfg(feature = "peer")] pub(crate) async fn join_nearby( &self, host: &HostId, - ) -> Result { + ) -> Result<(DeviceIdentity, std::net::IpAddr), JoinNearbyError> { let (reply, answer) = oneshot::channel(); self.bluetooth .send(crate::bluetooth::Command::JoinNearby { host: *host, reply }) @@ -542,10 +576,14 @@ impl EnrolHandle { } } - /// Fetch the nearby host's offer through the exchange, within the - /// machine's bound (R76 decision 19). + /// Fetch the nearby host's offer through the exchange, answered with + /// its proved identity, within the machine's bound (R76 decisions 19 + /// and 25). #[cfg(feature = "peer")] - pub(crate) async fn fetch_offer(&self, host: &HostId) -> Result { + pub(crate) async fn fetch_offer( + &self, + host: &HostId, + ) -> Result<(DeviceIdentity, HotspotOffer), BluetoothError> { let (reply, answer) = oneshot::channel(); self.bluetooth .send(crate::bluetooth::Command::Fetch { host: *host, reply }) diff --git a/crates/connetto-client/src/enrolment/tests.rs b/crates/connetto-client/src/enrolment/tests.rs index bbe90bbf..87a96477 100644 --- a/crates/connetto-client/src/enrolment/tests.rs +++ b/crates/connetto-client/src/enrolment/tests.rs @@ -2465,6 +2465,80 @@ async fn a_join_call_answers_from_the_machine_and_fails_when_the_machine_is_gone machine.await.expect("the machine ends"); } +/// The personal host's call answers from the machine's typed offer, the +/// details as the caller typed them, and a machine that drops the reply +/// fails the call within its bound (R76 decision 26). +#[cfg(feature = "peer")] +#[tokio::test] +async fn a_personal_host_call_answers_from_the_machine_and_fails_when_the_machine_is_gone() { + let (handle, _hotspot, mut bluetooth) = handle_with_machines().await; + let machine = tokio::spawn(async move { + let command = bluetooth.recv().await.expect("the machine runs"); + let crate::bluetooth::Command::PersonalHost { + ssid, + passphrase, + caller, + } = command + else { + panic!("the personal host's request reaches the Bluetooth machine"); + }; + assert_eq!(ssid, "iphone"); + assert_eq!(passphrase, "the passphrase"); + let offer = crate::hotspot::HotspotOffer::new( + "iphone", + "the passphrase", + crate::hotspot::HotspotSecurity::Wpa2, + Some(5678), + ); + let _ = caller.send(Ok(crate::bluetooth::Hosted { + offer, + beacon: Ok(()), + })); + }); + let hosted = handle + .host_personal_hotspot("iphone", "the passphrase") + .await + .expect("the machine answers"); + assert_eq!( + hosted.offer.ssid, "iphone", + "the machine's typed offer reaches the caller" + ); + assert!(hosted.beacon.is_ok(), "the beacon stands beside the offer"); + machine.await.expect("the machine ends"); + + let (handle, _hotspot, mut bluetooth) = handle_with_machines().await; + let machine = tokio::spawn(async move { + let command = bluetooth.recv().await.expect("the machine runs"); + // The machine dies without answering, so the reply is dropped. + drop(command); + }); + let error = handle + .host_personal_hotspot("iphone", "the passphrase") + .await + .expect_err("the machine is gone"); + assert!( + matches!(error, crate::hotspot::HotspotError::Failed), + "a gone machine fails the call, got {error:?}" + ); + machine.await.expect("the machine ends"); +} + +/// The personal host's bound runs when the machine answers nothing (R76 +/// decision 26). +#[cfg(feature = "peer")] +#[tokio::test(start_paused = true)] +async fn a_personal_host_call_answers_timed_out_when_the_machine_stays_silent() { + let (handle, _hotspot, _bluetooth) = handle_with_machines().await; + let error = handle + .host_personal_hotspot("iphone", "the passphrase") + .await + .expect_err("nothing answers the personal host"); + assert!( + matches!(error, crate::hotspot::HotspotError::TimedOut), + "the bound runs, got {error:?}" + ); +} + /// The enable's call answers from the machine's answer, and a machine that /// drops the reply fails the call within its bound (R76 decision 21). #[cfg(feature = "peer")] @@ -2500,6 +2574,18 @@ async fn an_enable_call_answers_from_the_machine_and_fails_when_the_machine_is_g machine.await.expect("the machine ends"); } +/// The identity a nearby host proves in the exchange, as the fake machines +/// answer it. +#[cfg(feature = "peer")] +fn nearby_host_identity() -> connetto_core::device_cert::DeviceIdentity { + connetto_core::device_cert::DeviceIdentity::new( + DeploymentId::from_uuid(uuid::Uuid::from_u128(7)), + "host", + connetto_core::device_cert::KeyId::from_bytes([7; 32]), + ) + .expect("an identity holds") +} + /// The nearby join's call answers from the machine's gateway, and a machine /// that drops the reply fails the call within its bound (R76 decision 19). #[cfg(feature = "peer")] @@ -2512,9 +2598,17 @@ async fn a_join_nearby_call_answers_from_the_machine_and_fails_when_the_machine_ panic!("the nearby join's request reaches the Bluetooth machine"); }; assert_eq!(host, 9, "the request names the host"); - let _ = reply.send(Ok("192.168.4.1".parse().expect("a private gateway"))); + let _ = reply.send(Ok(( + nearby_host_identity(), + "192.168.4.1".parse().expect("a private gateway"), + ))); }); - let gateway = handle.join_nearby(&9).await.expect("the machine answers"); + let (identity, gateway) = handle.join_nearby(&9).await.expect("the machine answers"); + assert_eq!( + identity, + nearby_host_identity(), + "the host's proven identity reaches the caller" + ); assert_eq!( gateway, "192.168.4.1" @@ -2555,14 +2649,22 @@ async fn a_fetch_call_answers_from_the_machine_and_fails_when_the_machine_is_gon panic!("the fetch's request reaches the Bluetooth machine"); }; assert_eq!(host, 3, "the request names the host"); - let _ = reply.send(Ok(crate::hotspot::HotspotOffer::new( - "camp", - "opensesame", - crate::hotspot::HotspotSecurity::Wpa2, - Some(5678), + let _ = reply.send(Ok(( + nearby_host_identity(), + crate::hotspot::HotspotOffer::new( + "camp", + "opensesame", + crate::hotspot::HotspotSecurity::Wpa2, + Some(5678), + ), ))); }); - let offer = handle.fetch_offer(&3).await.expect("the machine answers"); + let (identity, offer) = handle.fetch_offer(&3).await.expect("the machine answers"); + assert_eq!( + identity, + nearby_host_identity(), + "the host's proven identity reaches the caller" + ); assert_eq!(offer.ssid, "camp", "the machine's offer reaches the caller"); machine.await.expect("the machine ends"); diff --git a/crates/connetto-client/src/hotspot.rs b/crates/connetto-client/src/hotspot.rs index 03f2b209..b6e6c83b 100644 --- a/crates/connetto-client/src/hotspot.rs +++ b/crates/connetto-client/src/hotspot.rs @@ -21,12 +21,16 @@ use crate::ClientEvent; #[cfg(target_os = "android")] pub(crate) mod android; +#[cfg(target_os = "ios")] +pub(crate) mod apple; mod wifi_payload; pub use wifi_payload::WifiPayloadError; #[cfg(target_os = "android")] pub(crate) use android::{AndroidHotspotBackend, JoinedBind}; +#[cfg(target_os = "ios")] +pub(crate) use apple::AppleHotspotBackend; /// The time a hosted hotspot gets to start. pub(crate) const HOST_BOUND: Duration = Duration::from_secs(30); @@ -149,7 +153,7 @@ pub enum JoinError { not(any(target_os = "android", test)), expect( dead_code, - reason = "the Android backend and the proofs construct these standings, a non-Android build reads them through the tables only" + reason = "Android's backend and the tests construct these standings, the other systems host no regular hotspot" ) )] #[derive(Clone)] @@ -173,10 +177,10 @@ pub(crate) enum HostStatus { /// The join side's outcome, as the backend stands it (R76). #[cfg_attr( - not(any(target_os = "android", test)), + not(any(target_os = "android", target_os = "ios", test)), expect( dead_code, - reason = "the Android backend and the proofs construct these standings, a non-Android build reads them through the tables only" + reason = "the platform backends and the proofs construct these standings, a non-Apple build reads them through the tables only" ) )] #[derive(Clone)] @@ -214,11 +218,19 @@ pub(crate) trait HotspotBackend: Send + Sync { fn leave_join(&self); /// What the join side stands, for the machine's look. fn join_status(&self) -> JoinStatus; + /// Request the personal hotspot under the user's `ssid` and + /// `passphrase`, answering a refusal at once (R76 decision 26). + fn request_personal_host(&self, ssid: &str, passphrase: &str) -> Result<(), HotspotError>; + /// Whether the personal hotspot's network has landed, for the machine's + /// landing bound (R76 decision 26). + fn personal_landed(&self) -> bool; } /// The backend for a system without a hotspot (R76). +#[cfg(not(target_os = "ios"))] pub(crate) struct UnsupportedBackend; +#[cfg(not(target_os = "ios"))] impl HotspotBackend for UnsupportedBackend { fn request_host(&self) -> Result<(), HotspotError> { Err(HotspotError::Unsupported) @@ -239,6 +251,14 @@ impl HotspotBackend for UnsupportedBackend { fn join_status(&self) -> JoinStatus { JoinStatus::Pending } + + fn request_personal_host(&self, _ssid: &str, _passphrase: &str) -> Result<(), HotspotError> { + Err(HotspotError::Unsupported) + } + + fn personal_landed(&self) -> bool { + false + } } /// The one-shot answer the machine sends, the API methods awaiting it under @@ -258,6 +278,21 @@ enum HostState { Hosting { offer: HotspotOffer }, } +/// The personal host side's standing (R76 decisions 26 and 27). +#[derive(Clone, Debug)] +enum PersonalState { + /// Not hosting the personal hotspot. + Idle, + /// Hosting, with the typed offer and the landing's bound, where an + /// exchange has handed it out. + Hosting { + /// The offer the user typed, its peer port where the device serves. + offer: HotspotOffer, + /// The landing's bound, once an exchange hands the offer out. + landing: Option, + }, +} + /// The join side's standing (R76). enum JoinState { /// Not joined, nothing requested. @@ -303,6 +338,19 @@ pub(crate) enum Command { Host(Reply>), /// Stop hosting. StopHost, + /// Host the personal hotspot under the user's typed details, answered + /// with its offer (R76 decision 26). + PersonalHost { + /// The network's name, as the user types it. + ssid: String, + /// The network's passphrase, as the user types it. + passphrase: String, + /// The answer, with the offer. + reply: Reply>, + }, + /// The offer handed out by an exchange, the landing's bound starts + /// (R76 decision 26). + PersonalOfferHandedOut, /// Join the offer's network, answered with its gateway. Join { /// The network to join. @@ -330,6 +378,7 @@ pub(crate) struct Machine { /// Emit an event to the application. emit: Arc, host: HostState, + personal: PersonalState, join: JoinState, } @@ -352,6 +401,7 @@ impl Machine { dial, emit, host: HostState::Off, + personal: PersonalState::Idle, join: JoinState::Idle, } } @@ -372,11 +422,14 @@ impl Machine { } } - /// The offer the host side holds, while it hosts. + /// The offer the host side holds, while it hosts or personal-hosts. pub(crate) fn hosting_offer(&self) -> Option { match &self.host { HostState::Hosting { offer } => Some(offer.clone()), - _ => None, + _ => match &self.personal { + PersonalState::Hosting { offer, .. } => Some(offer.clone()), + PersonalState::Idle => None, + }, } } @@ -409,16 +462,36 @@ impl Machine { let _ = reply.send(Ok(answer)); } } - Command::StopHost => match core::mem::replace(&mut self.host, HostState::Off) { - HostState::Off => {} - HostState::Starting { replies, .. } => { - self.backend.stop_host(); + Command::StopHost => { + let regular = matches!( + self.host, + HostState::Starting { .. } | HostState::Hosting { .. } + ); + let personal = matches!(self.personal, PersonalState::Hosting { .. }); + if let HostState::Starting { replies, .. } = + core::mem::replace(&mut self.host, HostState::Off) + { for reply in replies { let _ = reply.send(Err(HotspotError::Failed)); } } - HostState::Hosting { .. } => self.backend.stop_host(), - }, + if personal { + self.personal = PersonalState::Idle; + } + if regular || personal { + self.backend.stop_host(); + } + } + Command::PersonalHost { + ssid, + passphrase, + reply, + } => self.host_personal(ssid, passphrase, reply), + Command::PersonalOfferHandedOut => { + if let PersonalState::Hosting { landing, .. } = &mut self.personal { + *landing = Some(tokio::time::Instant::now() + JOIN_BOUND); + } + } Command::Join { offer, reply } => { // The earlier request cancelled, the joined network // released and its bind stopped, before the new request @@ -458,6 +531,30 @@ impl Machine { } } + /// Host the Personal Hotspot the user switched on and typed in, keeping + /// a running landing bound (R76 decisions 26 and 27). + fn host_personal( + &mut self, + ssid: String, + passphrase: String, + reply: Reply>, + ) { + if let Err(err) = self.backend.request_personal_host(&ssid, &passphrase) { + let _ = reply.send(Err(err)); + return; + } + let landing = match &self.personal { + PersonalState::Hosting { landing, .. } => *landing, + PersonalState::Idle => None, + }; + let offer = HotspotOffer::new(ssid, passphrase, HotspotSecurity::Wpa2, (self.peer_port)()); + self.personal = PersonalState::Hosting { + offer: offer.clone(), + landing, + }; + let _ = reply.send(Ok(offer)); + } + /// Look at the backends' outcomes and apply the tables' transitions. #[expect( clippy::too_many_lines, @@ -527,6 +624,18 @@ impl Machine { (self.emit)(ClientEvent::HotspotStopped); } + // The personal host's landing, the bound run by the looks. + if let PersonalState::Hosting { landing, .. } = &mut self.personal + && let Some(deadline) = landing + { + if self.backend.personal_landed() { + *landing = None; + } else if tokio::time::Instant::now() >= *deadline { + *landing = None; + (self.emit)(ClientEvent::HotspotUnreached); + } + } + if matches!(self.join, JoinState::Requesting { .. }) { match self.backend.join_status() { JoinStatus::Available { @@ -627,7 +736,8 @@ impl Machine { if matches!( self.host, HostState::Starting { .. } | HostState::Hosting { .. } - ) { + ) || matches!(self.personal, PersonalState::Hosting { .. }) + { self.backend.stop_host(); } if matches!( @@ -640,17 +750,54 @@ impl Machine { } } self.host = HostState::Off; + self.personal = PersonalState::Idle; self.join = JoinState::Idle; } } + +/// How long an Apple join the system answered gets to move the interface +/// before it is asked for once more (R76 decision 28). +pub(crate) const REJOIN_AFTER: Duration = Duration::from_secs(15); + +/// Whether an Apple join is due its one more ask: the system answered it, +/// the interface has not moved, [`REJOIN_AFTER`] has passed since the ask, +/// and the join has not been asked twice (R76 decision 28). +#[cfg_attr( + not(any(target_os = "ios", test)), + expect(dead_code, reason = "only the Apple backend asks a join twice") +)] +pub(crate) fn rejoin_due( + answered: bool, + landed: bool, + waited: Duration, + asked_again: bool, +) -> bool { + answered && !landed && !asked_again && waited >= REJOIN_AFTER +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn an_answered_join_that_never_landed_is_asked_once_more() { + assert!(rejoin_due(true, false, REJOIN_AFTER, false)); + } + + #[test] + fn a_join_is_not_asked_again_early_unanswered_landed_or_twice() { + assert!(!rejoin_due(true, false, Duration::from_secs(14), false)); + assert!(!rejoin_due(false, false, REJOIN_AFTER * 2, false)); + assert!(!rejoin_due(true, true, REJOIN_AFTER * 2, false)); + assert!(!rejoin_due(true, false, REJOIN_AFTER * 2, true)); + } + /// A backend whose outcome the test steers, recording its calls. struct FakeBackend { host_request: Mutex>, join_request: Mutex>, + personal_request: Mutex>, + landed: Mutex, host_status: Mutex, join_status: Mutex, calls: Mutex>, @@ -662,6 +809,8 @@ mod tests { Self { host_request: Mutex::new(Ok(())), join_request: Mutex::new(Ok(())), + personal_request: Mutex::new(Err(HotspotError::Unsupported)), + landed: Mutex::new(false), host_status: Mutex::new(HostStatus::Pending), join_status: Mutex::new(JoinStatus::Pending), calls: Mutex::new(Vec::new()), @@ -722,6 +871,20 @@ mod tests { fn join_status(&self) -> JoinStatus { self.join_status.lock().clone() } + + fn request_personal_host( + &self, + _ssid: &str, + _passphrase: &str, + ) -> Result<(), HotspotError> { + self.calls.lock().push("request_personal_host"); + self.personal_request.lock().clone() + } + + fn personal_landed(&self) -> bool { + self.calls.lock().push("personal_landed"); + *self.landed.lock() + } } /// The machine over a fresh fake backend, with its seams' records. @@ -1019,6 +1182,243 @@ mod tests { h.machine.close(); assert_eq!(h.backend.counted("stop_host"), 2); } + /// The personal host row `host_personal_hotspot | Off | the typed + /// answer stands, the port where the device serves` (R76 decision 26). + #[test] + fn a_personal_host_request_answers_its_typed_offer() { + let mut h = Harness::new(true); + *h.backend.personal_request.lock() = Ok(()); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::PersonalHost { + ssid: "luca's iphone".into(), + passphrase: "the passphrase".into(), + reply, + }); + assert_eq!( + answer.try_recv().unwrap(), + Ok(HotspotOffer::new( + "luca's iphone", + "the passphrase", + HotspotSecurity::Wpa2, + Some(54321), + )) + ); + assert!(h.machine.hosting_offer().is_some()); + } + + /// The personal host row `host_personal_hotspot | the device does not + /// serve | the port stays out of the offer` (R76 decision 26). + #[test] + fn a_personal_host_request_without_serving_answers_its_portless_offer() { + let mut h = Harness::new(true); + *h.backend.personal_request.lock() = Ok(()); + *h.port.lock() = None; + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::PersonalHost { + ssid: "luca's iphone".into(), + passphrase: "the passphrase".into(), + reply, + }); + assert_eq!( + answer.try_recv().unwrap(), + Ok(HotspotOffer::new( + "luca's iphone", + "the passphrase", + HotspotSecurity::Wpa2, + None, + )) + ); + } + + /// The personal host row `host_personal_hotspot | Hosting | replace + /// the offer` (R76 decision 26). + #[test] + fn a_second_personal_host_request_replaces_the_offer() { + let mut h = Harness::new(true); + *h.backend.personal_request.lock() = Ok(()); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::PersonalHost { + ssid: "the first".into(), + passphrase: "one".into(), + reply, + }); + assert_eq!(answer.try_recv().unwrap().unwrap().ssid, "the first"); + let (second, mut second_answer) = oneshot::channel(); + h.machine.handle(Command::PersonalHost { + ssid: "the second".into(), + passphrase: "two".into(), + reply: second, + }); + assert_eq!( + second_answer.try_recv().unwrap().unwrap().ssid, + "the second" + ); + assert_eq!(h.machine.hosting_offer().unwrap().ssid, "the second"); + } + + /// The personal host row `host_personal_hotspot | the model cannot + /// host | the typed refusal stands` (R76 decision 26). + #[test] + fn a_refused_personal_host_request_answers_its_error() { + let mut h = Harness::new(true); + *h.backend.personal_request.lock() = Err(HotspotError::Incompatible); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::PersonalHost { + ssid: "luca's ipad".into(), + passphrase: "the passphrase".into(), + reply, + }); + assert_eq!(answer.try_recv().unwrap(), Err(HotspotError::Incompatible)); + assert!(h.machine.hosting_offer().is_none()); + } + + /// The personal host rows `an exchange hands the offer out | start the + /// landing bound of the join bound` and `host_personal_hotspot | the + /// bound runs only from the hand out` (R76 decision 26). + #[tokio::test(start_paused = true)] + async fn a_handout_starts_the_landing_bound() { + let mut h = Harness::new(true); + *h.backend.personal_request.lock() = Ok(()); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::PersonalHost { + ssid: "luca's iphone".into(), + passphrase: "the passphrase".into(), + reply, + }); + assert!(answer.try_recv().unwrap().is_ok()); + // The host alone starts no bound. + tokio::time::sleep(JOIN_BOUND + Duration::from_secs(1)).await; + h.machine.tick(); + assert!(h.events.lock().is_empty()); + // The hand out starts the bound, which has not run. + h.machine.handle(Command::PersonalOfferHandedOut); + h.machine.tick(); + assert!(h.events.lock().is_empty()); + // The bound runs, so the unreached stands. + tokio::time::sleep(JOIN_BOUND + Duration::from_secs(1)).await; + h.machine.tick(); + assert_eq!(h.events.lock().as_slice(), [ClientEvent::HotspotUnreached]); + } + + /// The personal host row `the network lands | end the landing bound` + /// (R76 decision 26). + #[tokio::test(start_paused = true)] + async fn a_landed_device_ends_the_landing_bound() { + let mut h = Harness::new(true); + *h.backend.personal_request.lock() = Ok(()); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::PersonalHost { + ssid: "luca's iphone".into(), + passphrase: "the passphrase".into(), + reply, + }); + assert!(answer.try_recv().unwrap().is_ok()); + h.machine.handle(Command::PersonalOfferHandedOut); + *h.backend.landed.lock() = true; + h.machine.tick(); + assert!(h.events.lock().is_empty()); + tokio::time::sleep(JOIN_BOUND + Duration::from_secs(1)).await; + h.machine.tick(); + assert!(h.events.lock().is_empty()); + } + + /// The personal host row `the landing's bound runs without a landing | + /// the unreached stands, the hosting continues` (R76 decision 26). + #[tokio::test(start_paused = true)] + async fn a_passed_landing_bound_emits_unreached_and_keeps_hosting() { + let mut h = Harness::new(true); + *h.backend.personal_request.lock() = Ok(()); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::PersonalHost { + ssid: "luca's iphone".into(), + passphrase: "the passphrase".into(), + reply, + }); + assert!(answer.try_recv().unwrap().is_ok()); + h.machine.handle(Command::PersonalOfferHandedOut); + tokio::time::sleep(JOIN_BOUND + Duration::from_secs(1)).await; + h.machine.tick(); + assert_eq!(h.events.lock().as_slice(), [ClientEvent::HotspotUnreached]); + // The hosting continues, so the offer and the bound stand again. + assert!(h.machine.hosting_offer().is_some()); + h.machine.handle(Command::PersonalOfferHandedOut); + tokio::time::sleep(JOIN_BOUND + Duration::from_secs(1)).await; + h.machine.tick(); + assert_eq!( + h.events.lock().as_slice(), + [ClientEvent::HotspotUnreached, ClientEvent::HotspotUnreached,] + ); + } + + /// The personal host row `stop_hotspot | Hosting | become Idle, the + /// backend's stop stands` (R76 decision 26). + #[test] + fn a_stop_closes_a_personal_host() { + let mut h = Harness::new(true); + *h.backend.personal_request.lock() = Ok(()); + let (reply, mut answer) = oneshot::channel(); + h.machine.handle(Command::PersonalHost { + ssid: "luca's iphone".into(), + passphrase: "the passphrase".into(), + reply, + }); + assert!(answer.try_recv().unwrap().is_ok()); + h.machine.handle(Command::StopHost); + assert_eq!(h.backend.counted("stop_host"), 1); + assert!(h.machine.hosting_offer().is_none()); + } + + /// The personal host row `Client closed | Hosting | become Idle, the + /// backend's stop stands` (R76 decision 26). + #[test] + fn a_closed_client_closes_a_personal_host() { + let mut h = Harness::new(true); + *h.backend.personal_request.lock() = Ok(()); + let (reply, _answer) = oneshot::channel(); + h.machine.handle(Command::PersonalHost { + ssid: "luca's iphone".into(), + passphrase: "the passphrase".into(), + reply, + }); + h.machine.close(); + assert_eq!(h.backend.counted("stop_host"), 1); + assert!(h.machine.hosting_offer().is_none()); + } + + /// The personal host row `host_personal_hotspot | the system cannot + /// host a personal hotspot | the typed refusal stands, and its network + /// never lands` (R76 decision 26). + #[cfg(not(target_os = "ios"))] + #[test] + fn a_system_without_a_personal_hotspot_refuses_the_request() { + let backend = UnsupportedBackend; + assert!(!backend.personal_landed()); + let mut machine = Machine::new( + Arc::new(backend) as Arc, + false, + Arc::new(|| Some(54321)), + Arc::new(|_subnet| {}), + Arc::new(|_addr, _answer| {}), + Arc::new(|_event| {}), + ); + let (reply, mut answer) = oneshot::channel(); + machine.handle(Command::PersonalHost { + ssid: "iphone".into(), + passphrase: "the passphrase".into(), + reply, + }); + assert_eq!(answer.try_recv().unwrap(), Err(HotspotError::Unsupported)); + } + + /// The personal host row `an exchange hands the offer out | no personal + /// host stands | nothing starts` (R76 decision 26). + #[test] + fn a_handout_without_a_personal_host_starts_nothing() { + let mut h = Harness::new(true); + h.machine.handle(Command::PersonalOfferHandedOut); + h.machine.tick(); + assert!(h.events.lock().is_empty()); + } /// The join row `join_hotspot | Idle | a missing permission answers /// MissingPermission`, and the next request still goes. diff --git a/crates/connetto-client/src/hotspot/android.rs b/crates/connetto-client/src/hotspot/android.rs index 85b48d7d..971ef7f5 100644 --- a/crates/connetto-client/src/hotspot/android.rs +++ b/crates/connetto-client/src/hotspot/android.rs @@ -365,6 +365,16 @@ impl HotspotBackend for AndroidHotspotBackend { _ => JoinStatus::Pending, } } + + // Android hosts from the app, so a hand-started personal hotspot has no + // place here (R76 decision 26). + fn request_personal_host(&self, _ssid: &str, _passphrase: &str) -> Result<(), HotspotError> { + Err(HotspotError::Unsupported) + } + + fn personal_landed(&self) -> bool { + false + } } /// The dials' bind to the joined network, for the targets it lies in (R76). diff --git a/crates/connetto-client/src/hotspot/apple.rs b/crates/connetto-client/src/hotspot/apple.rs new file mode 100644 index 00000000..d5b879cc --- /dev/null +++ b/crates/connetto-client/src/hotspot/apple.rs @@ -0,0 +1,190 @@ +//! The Apple hotspot backend, over the system's join API (R76). +//! +//! The join side asks the platform's `NEHotspotConfigurationManager` to join +//! the offered network, and reads the joined network's address, subnet and +//! gateway from the system. The host side stands on the user's own Personal +//! Hotspot, which the app can only describe, never start. + +use parking_lot::RwLock; + +use connetto_peer_apple::{self as apple, Idiom}; + +use super::{ + HostStatus, HotspotBackend, HotspotError, HotspotOffer, HotspotSecurity, JoinError, JoinStatus, +}; + +/// The personal hotspot's lane, the platform's bridge for it. +const PERSONAL_LANE: &str = "bridge100"; + +/// The Apple hotspot backend, over `connetto-peer-apple` (R76). +pub(crate) struct AppleHotspotBackend { + /// The typed personal offer, where the device stands one. + personal: RwLock>, + /// The SSID the join side last asked for, for its release. + joined_ssid: RwLock>, + /// The join side has stood available once. + was_available: RwLock, + /// The interface's address when the join was asked, which a joined + /// network replaces. + before: RwLock>, + /// The offer the join side last asked for, when, and whether it was + /// asked once more (R76 decision 28). + asked: RwLock>, +} + +impl AppleHotspotBackend { + /// Ask the system for the network once more where decision 28's rule + /// says the first ask landed nowhere. + fn rejoin_if_due(&self, answered: bool, landed: bool) { + let mut asked = self.asked.write(); + let Some((offer, at, again)) = asked.as_mut() else { + return; + }; + if !super::rejoin_due(answered, landed, at.elapsed(), *again) { + return; + } + *again = true; + tracing::debug!(ssid = %offer.ssid, "the joined network never landed, asking once more"); + if let Err(err) = + apple::hotspot::join(&offer.ssid, &offer.passphrase, security(offer.security)) + { + tracing::debug!(?err, "the second ask for the network was refused"); + } + } + + /// The backend's new. + pub(crate) fn new() -> Self { + Self { + personal: RwLock::new(None), + joined_ssid: RwLock::new(None), + was_available: RwLock::new(false), + before: RwLock::new(None), + asked: RwLock::new(None), + } + } +} + +/// Map the client's security to the platform's. +fn security(security: HotspotSecurity) -> apple::hotspot::Security { + match security { + HotspotSecurity::Wpa2 => apple::hotspot::Security::Wpa2, + HotspotSecurity::Wpa3 => apple::hotspot::Security::Wpa3, + } +} + +/// The platform's join refusal as the client's. +fn join_refusal(error: &apple::Error) -> JoinError { + match error { + apple::Error::Unsupported => JoinError::Unsupported, + // A WPA3 setter absent at run time is a refusal for this join, not + // for the system's joins as a whole. + _ => JoinError::Failed, + } +} + +/// Whether the personal hotspot's network has landed. +fn personal_landed() -> bool { + if_addrs::get_if_addrs().is_ok_and(|interfaces| { + interfaces.iter().any(|interface| { + interface.name == PERSONAL_LANE && matches!(interface.addr, if_addrs::IfAddr::V4(_)) + }) + }) +} + +impl HotspotBackend for AppleHotspotBackend { + fn request_host(&self) -> Result<(), HotspotError> { + // The app cannot start the platform's hotspot, so the request stands + // refused (R76 decision 26). + Err(HotspotError::Unsupported) + } + + fn stop_host(&self) { + *self.personal.write() = None; + } + + fn host_status(&self) -> HostStatus { + // The personal offer stands on the user's own hotspot, which the + // machine's landing bound confirms through `personal_landed`. + HostStatus::Pending + } + + fn request_join(&self, offer: &HotspotOffer) -> Result<(), JoinError> { + *self.before.write() = apple::hotspot::joined().map(|joined| joined.address); + match apple::hotspot::join(&offer.ssid, &offer.passphrase, security(offer.security)) { + Ok(()) => { + *self.asked.write() = Some((offer.clone(), tokio::time::Instant::now(), false)); + *self.joined_ssid.write() = Some(offer.ssid.clone()); + *self.was_available.write() = false; + Ok(()) + } + Err(error) => Err(join_refusal(&error)), + } + } + + fn leave_join(&self) { + *self.asked.write() = None; + let ssid = self.joined_ssid.write().take(); + if let Some(ssid) = ssid { + let _ = apple::hotspot::leave(&ssid); + } + *self.was_available.write() = false; + } + + fn join_status(&self) -> JoinStatus { + // The network stands joined only once the system answered the join, + // and either moved the interface off the address it stood on before + // or found it already on the network, since a failed join leaves the + // device on its previous network. + let joining = match apple::hotspot::completion() { + // The system will not give the network, or the user declined its + // join dialog (R76). + Some(Err(_)) => return JoinStatus::Unavailable, + Some(Ok(joining)) => Some(joining), + None => None, + }; + let joined = apple::hotspot::joined(); + let landed = match joining { + Some(apple::hotspot::Joining::AlreadyThere) => true, + Some(apple::hotspot::Joining::Joined) => { + joined.map(|joined| joined.address) != *self.before.read() + } + None => false, + }; + self.rejoin_if_due(joining.is_some(), landed); + match joined { + // The network is up once its address and its gateway stand. A + // missing gateway stays pending for the machine's bound. + Some(apple::hotspot::Joined { + address, + prefix, + gateway: Some(gateway), + }) if landed => { + *self.was_available.write() = true; + JoinStatus::Available { + address, + prefix, + gateway, + } + } + _ if *self.was_available.read() => { + tracing::debug!(?joined, "the joined network reads lost"); + JoinStatus::Lost + } + _ => JoinStatus::Pending, + } + } + + fn request_personal_host(&self, ssid: &str, passphrase: &str) -> Result<(), HotspotError> { + // Only a phone hosts a Personal Hotspot, so anything but a phone + // stands refused (R76 decision 27). + if apple::device_idiom() != Idiom::Phone { + return Err(HotspotError::Incompatible); + } + *self.personal.write() = Some((ssid.to_owned(), passphrase.to_owned())); + Ok(()) + } + + fn personal_landed(&self) -> bool { + personal_landed() + } +} diff --git a/crates/connetto-client/src/lib.rs b/crates/connetto-client/src/lib.rs index a55523b7..8b8b4ad9 100644 --- a/crates/connetto-client/src/lib.rs +++ b/crates/connetto-client/src/lib.rs @@ -1273,10 +1273,14 @@ pub enum ClientEvent { /// The instance's leaf fingerprint. fingerprint: connetto_peer::Fingerprint, }, - /// The device's hotspot stopped hosting, by the system or the user (R76). + /// The hotspot stopped, on a system stop (R76). #[cfg(feature = "peer")] HotspotStopped, - /// The device left its joined hotspot (R76). + /// The personal hotspot's offer was handed out but no device landed on + /// the network within its bound (R76 decision 26). + #[cfg(feature = "peer")] + HotspotUnreached, + /// The joined network was left (R76). #[cfg(feature = "peer")] HotspotLeft, /// The redial to its joined hotspot's gateway passed its bound with no @@ -1289,14 +1293,15 @@ pub enum ClientEvent { /// The standing the platform reports. state: crate::bluetooth::BluetoothState, }, - /// A nearby host's beacon was seen, or its prefix changed (R76 decision - /// 19). + /// A nearby host's beacon was seen, or its beacon changed (R76 + /// decisions 19 and 25). #[cfg(feature = "peer")] HostNearby { /// The host's device. host: crate::bluetooth::HostId, - /// The beacon's prefix. - prefix: [u8; 8], + /// The beacon's prefix, where the beacon carries one (R76 decision + /// 25). + prefix: Option<[u8; 8]>, /// The signal the beacon carried. rssi: Option, }, diff --git a/crates/connetto-peer-apple/Cargo.toml b/crates/connetto-peer-apple/Cargo.toml new file mode 100644 index 00000000..cc9959cf --- /dev/null +++ b/crates/connetto-peer-apple/Cargo.toml @@ -0,0 +1,88 @@ +[package] +name = "connetto-peer-apple" +description = "The Apple platform bridge for the connetto peer link, the Bluetooth peripheral the host serves its exchange over and the Network Extension join a joiner's device asks the system for, in the workspace's second unsafe crate." +readme = "README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish = false + +# The workspace forbids unsafe code, and a forbid cannot be lowered in a crate +# that inherits it, so this crate restates the workspace's lints with +# `unsafe_code` at deny and lifts it only on the module that calls Apple. +[lints.rust] +missing_docs = "forbid" +unused_macro_rules = "forbid" +unconditional_recursion = "forbid" +unreachable_patterns = "forbid" +unused_import_braces = "forbid" +unused_must_use = "forbid" +unsafe_code = "deny" +unsafe_op_in_unsafe_fn = "forbid" +deprecated = "deny" +unknown_lints = "allow" + +[lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "warn", priority = -1 } +must_use_candidate = "allow" +module_name_repetitions = "allow" +allow_attributes = "deny" +allow_attributes_without_reason = "deny" +undocumented_unsafe_blocks = "deny" +multiple_unsafe_ops_per_block = "deny" + +[dependencies] +thiserror.workspace = true + +[target.'cfg(target_os = "ios")'.dependencies] +# The routing table the joined network's gateway is read from. +libc = "0.2" +# The beacon's UUIDs, the peer's own constants, read by the iOS bridge (R76). +connetto-peer = { path = "../connetto-peer", features = ["bluetooth"] } +parking_lot = "0.12" +dispatch2 = "0.3.1" +block2 = { version = "0.6", default-features = false, features = ["alloc"] } +objc2 = { version = "0.6.4", default-features = false, features = ["std"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = [ + "std", + "block2", + "NSArray", + "NSData", + "NSDictionary", + "NSError", + "NSObject", + "NSString", + "NSUUID", +] } +objc2-core-bluetooth = { version = "0.3.2", default-features = false, features = [ + "std", + "dispatch2", + "CBAdvertisementData", + "CBAttribute", + "CBATTRequest", + "CBCentral", + "CBCharacteristic", + "CBDefines", + "CBError", + "CBManager", + "CBPeer", + "CBPeripheral", + "CBPeripheralManager", + "CBPeripheralManagerConstants", + "CBService", + "CBUUID", +] } +objc2-network-extension = { version = "0.3.2", default-features = false, features = [ + "std", + "block2", +] } +objc2-ui-kit = { version = "0.3.2", default-features = false, features = [ + "std", + "UIDevice", +] } +if-addrs = "0.15" diff --git a/crates/connetto-peer-apple/README.md b/crates/connetto-peer-apple/README.md new file mode 100644 index 00000000..0531ce21 --- /dev/null +++ b/crates/connetto-peer-apple/README.md @@ -0,0 +1,26 @@ +# connetto-peer-apple + +[![Tests](https://github.com/LucaCappelletti94/connetto-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/LucaCappelletti94/connetto-rs/actions) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/LucaCappelletti94/connetto-rs/blob/main/LICENSE) +[![Coverage](https://codecov.io/gh/LucaCappelletti94/connetto-rs/graph/badge.svg)](https://codecov.io/gh/LucaCappelletti94/connetto-rs) + +The Apple platform bridge for connetto's peer link (R76). `bluetooth` is the host's GATT service and its beacon advertisement, over one process-wide `CBPeripheralManager` on its one serial dispatch queue. `hotspot` is the joiner's `NEHotspotConfiguration` join over the system's shared manager, and its read of the network the device joined. `device_name` and `device_idiom` read `UIDevice` off the app's main queue. This is the workspace's second unsafe crate, and every `unsafe` block sits in its platform module. + +Every public call hops onto the queue that owns its work, waited on at most [`HOP_WITHIN`], and the manager's delegate only pushes events into a shared list, so it never calls back into the public API. `notify` holds a frame the platform cannot take yet, and sends the held frames when the platform says it is ready. The platform's peripheral role cannot cancel a central's connection, so `disconnect` stands ready. + +The platform answers the device's name only under the `com.apple.developer.device-information.user-assigned-device-name` entitlement, and its generic model name without it, which `device_name` reads as `None`. A WPA3 join asks the platform for its `setWPA3PersonalPassphrase:` selector, and answers `Missing` where the platform does not. + +On every target but iOS and iPadOS the functions answer `Unsupported` or empty, and the device's idiom is `Other`. + +```rust +assert!(connetto_peer_apple::device_name().is_none()); +assert_eq!(connetto_peer_apple::device_idiom(), connetto_peer_apple::Idiom::Other); +assert!(matches!( + connetto_peer_apple::bluetooth::state(), + connetto_peer_apple::bluetooth::State::Unsupported +)); +assert!(matches!( + connetto_peer_apple::hotspot::completion(), + None +)); +``` diff --git a/crates/connetto-peer-apple/src/lib.rs b/crates/connetto-peer-apple/src/lib.rs new file mode 100755 index 00000000..b71bbbb9 --- /dev/null +++ b/crates/connetto-peer-apple/src/lib.rs @@ -0,0 +1,421 @@ +#![doc = include_str!("../README.md")] + +use std::time::Duration; + +/// The bound one hop onto the platform's owning queues gets (R76). +pub const HOP_WITHIN: Duration = Duration::from_secs(10); + +/// Why the platform's frameworks could not do what was asked (R76). +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// A framework reported an error. + #[error("the {domain} framework reported {code}: {description}")] + Apple { + /// The error domain. + domain: String, + /// The error code within the domain. + code: isize, + /// The framework's description of the error. + description: String, + }, + /// A platform facility is absent at run time. + #[error("the platform facility {0} is absent at run time")] + Missing(String), + /// The platform did not answer within [`HOP_WITHIN`]. + #[error("the platform did not answer within {0:?}")] + TimedOut(Duration), + /// This target has no such facility. + #[error("this target has no such facility")] + Unsupported, +} + +/// The device's role in the system's interface families (R76). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Idiom { + /// A phone. + Phone, + /// A pad. + Pad, + /// Neither, including every non-Apple target. + Other, +} + +/// The device's user-set name, or `None` for the platform's generic answer. +/// +/// iOS answers the user's name only under the +/// `com.apple.developer.device-information.user-assigned-device-name` entitlement, +/// and answers its generic model name without it, which this reads as `None` +/// (R76 decision 17). +pub fn device_name() -> Option { + crate::platform::device::device_name() +} + +/// The device's interface idiom. +/// +/// A Wi-Fi pad's Personal Hotspot cannot be turned on from an app, so the +/// client's hotspot backend reads this to answer a typed refusal for a +/// personal host on anything but a phone (R76 decision 27). +pub fn device_idiom() -> Idiom { + crate::platform::device::device_idiom() +} + +/// The names the platform answers for a device it does not name. +#[cfg_attr( + not(any(target_os = "ios", test)), + expect( + dead_code, + reason = "the iOS platform and the tests read these, a host build without tests uses neither" + ) +)] +const GENERIC_NAMES: [&str; 3] = ["iPhone", "iPad", "iPod"]; + +/// The domain the system's join answers in. +#[cfg_attr( + not(any(target_os = "ios", test)), + expect( + dead_code, + reason = "the iOS platform and the tests read this, a host build without tests uses neither" + ) +)] +pub(crate) const HOTSPOT_JOIN_DOMAIN: &str = "NEHotspotConfigurationErrorDomain"; + +/// The code that domain answers for a network the device already stands on. +#[cfg_attr( + not(any(target_os = "ios", test)), + expect( + dead_code, + reason = "the iOS platform and the tests read this, a host build without tests uses neither" + ) +)] +pub(crate) const HOTSPOT_ALREADY_ASSOCIATED: isize = 7; + +/// Whether the platform's name answer stands generic, so [`device_name`] +/// reads `None` (R76 decision 17). +#[cfg_attr( + not(any(target_os = "ios", test)), + expect( + dead_code, + reason = "the iOS platform and the tests call this, a host build without tests uses neither" + ) +)] +pub(crate) fn is_generic_name(name: &str) -> bool { + GENERIC_NAMES.contains(&name) +} + +/// Whether the system's join error stands for a network the device already +/// stands on, so the join's goal stands reached (R76). +#[cfg_attr( + not(any(target_os = "ios", test)), + expect( + dead_code, + reason = "the iOS platform and the tests call this, a host build without tests uses neither" + ) +)] +pub(crate) fn join_already_associated(domain: &str, code: isize) -> bool { + domain == HOTSPOT_JOIN_DOMAIN && code == HOTSPOT_ALREADY_ASSOCIATED +} + +/// One hex digit's value, sixteen standing for invalid. +#[cfg_attr( + not(any(target_os = "ios", test)), + expect( + dead_code, + reason = "the iOS platform and the tests call this, a host build without tests uses neither" + ) +)] +pub(crate) fn nibble(byte: u8) -> u8 { + match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + b'A'..=b'F' => byte - b'A' + 10, + _ => 16, + } +} + +/// The first eight bytes of a canonical UUID text, big-endian, the device +/// key the exchange stands on (R76). +#[cfg_attr( + not(any(target_os = "ios", test)), + expect( + dead_code, + reason = "the iOS platform and the tests call this, a host build without tests uses neither" + ) +)] +pub(crate) fn key_from_uuid_text(text: &str) -> Option { + let bytes = text.as_bytes(); + if bytes.len() != 36 + || bytes[8] != b'-' + || bytes[13] != b'-' + || bytes[18] != b'-' + || bytes[23] != b'-' + { + return None; + } + let starts = [0, 2, 4, 6, 9, 11, 14, 16]; + let mut out = [0u8; 8]; + for (pair, position) in out.iter_mut().zip(starts) { + let hi = nibble(bytes[position]); + let lo = nibble(bytes[position + 1]); + if hi == 16 || lo == 16 { + return None; + } + *pair = hi * 16 + lo; + } + Some(u64::from_be_bytes(out)) +} + +#[cfg(test)] +mod tests { + use super::{ + HOTSPOT_JOIN_DOMAIN, is_generic_name, join_already_associated, key_from_uuid_text, + }; + + #[test] + fn the_device_key_takes_the_uuids_first_half_big_endian() { + let text = "a9952637-85d5-4071-9ed8-c28bd7ba670c"; + assert_eq!(key_from_uuid_text(text), Some(0xa995_2637_85d5_4071)); + } + + #[test] + fn the_device_key_rejects_short_and_non_hex_text() { + assert_eq!(key_from_uuid_text("a995"), None); + assert_eq!( + key_from_uuid_text("z9952637-85d5-4071-9ed8-c28bd7ba670c"), + None + ); + } + + #[test] + fn the_platforms_generic_names_stand_generic() { + assert!(is_generic_name("iPhone")); + assert!(is_generic_name("iPad")); + assert!(!is_generic_name("Luca's iPhone")); + assert!(!is_generic_name("")); + } + + #[test] + fn the_join_domain_stands_already_associated_only_there() { + assert!(join_already_associated(HOTSPOT_JOIN_DOMAIN, 7)); + assert!(!join_already_associated(HOTSPOT_JOIN_DOMAIN, 6)); + assert!(!join_already_associated("OtherDomain", 7)); + } +} + +/// The host's Bluetooth, the GATT service the peer exchange runs over and +/// the beacon advertisement that points to it (R76). +pub mod bluetooth { + /// The radio's standing, as the platform reports it (R76 decision 21). + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum State { + /// The radio is on and the app may use it. + Ready, + /// The radio is off, or its standing is not settled yet. + Off, + /// The app may not use the radio. + NotPermitted, + /// The platform has no such radio. + Unsupported, + } + + /// How a prompt action ended, once it settled. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum PromptOutcome { + /// The standing was already the call's, so nothing was asked. + NotAsked, + /// The platform asked, and the user declined. + Declined, + } + + /// An event the host's GATT service reports (R76). + #[derive(Clone, Debug)] + pub enum Event { + /// A central subscribed to the outbox, `mtu` its frame length. + Connected { + /// The central's key, the first eight bytes of its identifier. + device: u64, + /// The central's frame length. + mtu: u16, + }, + /// A central wrote to the inbox. + Chunk { + /// The central's key. + device: u64, + /// What the central wrote. + bytes: Vec, + }, + /// A central's subscription ended. + Disconnected { + /// The central's key. + device: u64, + }, + /// The platform's frameworks failed at the service's work. + Failed(String), + } + + /// The radio's standing (R76 decision 21). + /// + /// Before a manager exists the app's Bluetooth authorization answers, + /// `NotPermitted` until the user allowed the app, since creating the + /// manager is the platform's strongest action and asks for that access. + /// An app already allowed creates its manager without the power alert + /// and reads `Off` until the radio's standing arrives. + pub fn state() -> State { + crate::platform::peripheral::state() + } + + /// Run the platform's strongest action once (R76 decision 21). + /// + /// On Apple the action creates the manager with its power alert where + /// that stands to show, and recreates the manager when the radio is off + /// to show it again. + /// + /// # Errors + /// + /// A framework error where the platform answers one. + pub fn prompt() -> Result<(), crate::Error> { + crate::platform::peripheral::prompt() + } + + /// The last [`prompt`]'s outcome, once it settles. + /// + /// It answers `Some` once the standing is terminal for the prompt, and + /// `None` while the platform's alert may still be on screen, since the + /// platform reports no decline for its power alert and the caller's + /// bound ends a prompt that never settles. + pub fn prompt_outcome() -> Option { + crate::platform::peripheral::prompt_outcome() + } + + /// Open the GATT service and begin its beacon advertisement (R76). + /// + /// The advertisement carries the service's UUID alone (R76 decision 25), + /// and the service's add and the advertisement's start settle on the + /// platform's own queue, so this answer may stand before the + /// advertisement is live. + /// + /// # Errors + /// + /// A framework error where the platform answers one, or its queue not + /// answering within its bound. + pub fn start() -> Result<(), crate::Error> { + crate::platform::peripheral::start() + } + + /// Stop the advertisement and close the service (R76). + /// + /// # Errors + /// + /// A framework error where the platform answers one, or its queue not + /// answering within its bound. + pub fn stop() -> Result<(), crate::Error> { + crate::platform::peripheral::stop() + } + + /// Take the events the service has reported since the last call. + pub fn poll() -> Vec { + crate::platform::peripheral::poll() + } + + /// Send one outbox notification to a subscribed central. + /// + /// A frame the platform cannot take yet is held, and sent when the + /// platform says it is ready, so back-pressure drops no frame. + /// + /// # Errors + /// + /// A framework error where the platform answers one, or its queue not + /// answering within its bound. + pub fn notify(device: u64, bytes: &[u8]) -> Result<(), crate::Error> { + crate::platform::peripheral::notify(device, bytes) + } + + /// Close the central's connection. + /// + /// The platform's peripheral role cannot cancel a central's connection + /// (R76), so this stands ready and records nothing. + pub fn disconnect(device: u64) { + crate::platform::peripheral::disconnect(device); + } +} + +/// The joiner's hotspot, the network join asked of the system and the read +/// of the network the device joined (R76). +pub mod hotspot { + use std::net::{IpAddr, Ipv4Addr}; + + /// The network's security, as the platform's join API knows it. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum Security { + /// WPA2 personal. + Wpa2, + /// WPA3 personal. + Wpa3, + } + + /// How the system answered a join it took. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum Joining { + /// The system joined the network. + Joined, + /// The device already stood on the network. + AlreadyThere, + } + + /// What a joined network stands at, once it stands. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct Joined { + /// The network's address on the device's interface. + pub address: Ipv4Addr, + /// The address's prefix length, in bits. + pub prefix: u8, + /// The network's gateway, once the system reports a default route. + pub gateway: Option, + } + + /// Ask the system to join a network (R76). + /// + /// The system answers with its completion, which this module records. + /// Read it with [`completion`]. A user's decline of the system's join + /// dialog answers there as an error, not here. + /// + /// # Errors + /// + /// A framework error where the platform answers one, and its WPA3 + /// selector standing absent. + pub fn join(ssid: &str, passphrase: &str, security: Security) -> Result<(), crate::Error> { + crate::platform::hotspot::join(ssid, passphrase, security) + } + + /// The system's answer to the last [`join`], once it answers. + pub fn completion() -> Option> { + crate::platform::hotspot::completion() + } + + /// Release the system's standing for a network (R76). + /// + /// # Errors + /// + /// A framework error where the platform answers one. + pub fn leave(ssid: &str) -> Result<(), crate::Error> { + crate::platform::hotspot::leave(ssid) + } + + /// What a joined network stands at (R76 decision 26). + /// + /// It answers `Some` once the device's interface carries the joined + /// network's address, and its gateway is `None` while the system reports + /// no default route. + pub fn joined() -> Option { + crate::platform::hotspot::joined() + } +} + +#[cfg(any(target_os = "ios", test))] +mod route; + +#[cfg(not(target_os = "ios"))] +#[path = "unsupported.rs"] +mod platform; + +#[cfg(target_os = "ios")] +mod platform; diff --git a/crates/connetto-peer-apple/src/platform/device.rs b/crates/connetto-peer-apple/src/platform/device.rs new file mode 100644 index 00000000..48b3ff40 --- /dev/null +++ b/crates/connetto-peer-apple/src/platform/device.rs @@ -0,0 +1,49 @@ +//! The device's name and idiom, off the app's main queue (R76). + +use dispatch2::DispatchQueue; +use objc2::MainThreadMarker; +use objc2_ui_kit::{UIDevice, UIUserInterfaceIdiom}; + +use crate::{Error, HOP_WITHIN, Idiom, is_generic_name}; + +/// The device's user-set name, or `None` for the platform's generic answer. +pub(crate) fn device_name() -> Option { + on_main(|device| { + let name = device.name().to_string(); + (!is_generic_name(&name)).then_some(name) + }) + .ok() + .flatten() +} + +/// The device's interface idiom. +pub(crate) fn device_idiom() -> Idiom { + on_main(|device| match device.userInterfaceIdiom() { + UIUserInterfaceIdiom::Phone => Idiom::Phone, + UIUserInterfaceIdiom::Pad => Idiom::Pad, + _ => Idiom::Other, + }) + .unwrap_or(Idiom::Other) +} + +/// Run `read` on the app's main queue and take its answer. +/// +/// `UIDevice`'s instance methods require the main thread, and the app's main +/// run loop runs the work this queue posts there. +fn on_main R + Send + 'static>( + read: F, +) -> Result { + let (tx, rx) = std::sync::mpsc::channel(); + DispatchQueue::main().exec_async(move || { + let answer = MainThreadMarker::new().map_or( + Err(Error::Missing("the app's main thread".to_owned())), + |marker| { + let device = UIDevice::currentDevice(marker); + Ok(read(&device)) + }, + ); + let _ = tx.send(answer); + }); + rx.recv_timeout(HOP_WITHIN) + .map_err(|_| Error::TimedOut(HOP_WITHIN))? +} diff --git a/crates/connetto-peer-apple/src/platform/hotspot.rs b/crates/connetto-peer-apple/src/platform/hotspot.rs new file mode 100644 index 00000000..0550eb95 --- /dev/null +++ b/crates/connetto-peer-apple/src/platform/hotspot.rs @@ -0,0 +1,216 @@ +//! The system's join, and the read of the joined network (R76). + +use std::net::IpAddr; +use std::sync::LazyLock; + +use block2::RcBlock; +use objc2::{AnyThread, msg_send, rc::Retained, sel}; +use objc2_foundation::{NSError, NSObjectProtocol, NSString}; +use objc2_network_extension::{NEHotspotConfiguration, NEHotspotConfigurationManager}; +use parking_lot::Mutex; + +use crate::hotspot::{Joined, Joining, Security}; +use crate::{Error, join_already_associated}; + +/// What the system's join stands at. +#[derive(Default)] +struct Standing { + /// The system's answer to the last join, once it answers. + completion: Option>, +} + +static STANDING: LazyLock> = LazyLock::new(|| Mutex::new(Standing::default())); + +/// Ask the system to join a network. +pub(crate) fn join(ssid: &str, passphrase: &str, security: Security) -> Result<(), Error> { + let configuration = configuration(ssid, passphrase, security)?; + // SAFETY: the framework answers the process-wide manager with nothing + // else to return. + let manager = unsafe { NEHotspotConfigurationManager::sharedManager() }; + // The answer is cleared before the join, since the system may answer + // before the call returns. + STANDING.lock().completion = None; + let handler: RcBlock = RcBlock::new(move |error: *mut NSError| { + // SAFETY: the system passes either null or an error it owns for the + // duration of this call, and the error is copied before the call + // returns. + let error = unsafe { error.as_ref() }; + let answer = match error { + // The device already stands on the network, so the join's goal + // stands reached. + Some(error) if join_already_associated(&error.domain().to_string(), error.code()) => { + Ok(Joining::AlreadyThere) + } + Some(error) => Err(Error::Apple { + domain: error.domain().to_string(), + code: error.code(), + description: error.localizedDescription().to_string(), + }), + None => Ok(Joining::Joined), + }; + STANDING.lock().completion = Some(answer); + }); + // SAFETY: the system copies the block before returning, so dropping ours + // after the call is sound. + unsafe { + manager.applyConfiguration_completionHandler(&configuration, Some(&handler)); + } + Ok(()) +} + +/// The system's answer to the last join, once it answers. +pub(crate) fn completion() -> Option> { + STANDING.lock().completion.clone() +} + +/// Release the system's standing for a network. +#[expect( + clippy::unnecessary_wraps, + reason = "the release is synchronous and answers no error" +)] +pub(crate) fn leave(ssid: &str) -> Result<(), Error> { + // SAFETY: the framework answers the process-wide manager with nothing + // else to return. + let manager = unsafe { NEHotspotConfigurationManager::sharedManager() }; + // SAFETY: the release is synchronous and answers no error. + unsafe { manager.removeConfigurationForSSID(&NSString::from_str(ssid)) }; + STANDING.lock().completion = None; + Ok(()) +} + +/// What a joined network stands at. +pub(crate) fn joined() -> Option { + let (address, prefix) = if_addrs::get_if_addrs() + .ok()? + .into_iter() + .find_map(|interface| { + (interface.name == "en0") + .then_some(interface.addr) + .and_then(|addr| match addr { + if_addrs::IfAddr::V4(v4) => Some((v4.ip, v4.prefixlen)), + if_addrs::IfAddr::V6(_) => None, + }) + })?; + Some(Joined { + address, + prefix, + gateway: default_route_gateway(), + }) +} + +/// The system's join configuration, WPA2 typed and WPA3 by its raw selector. +fn configuration( + ssid: &str, + passphrase: &str, + security: Security, +) -> Result, Error> { + let ssid = NSString::from_str(ssid); + let passphrase = NSString::from_str(passphrase); + let configuration = match security { + Security::Wpa2 => { + // SAFETY: the init answers a configuration the caller owns, from + // valid strings the platform copies. + unsafe { + NEHotspotConfiguration::initWithSSID_passphrase_isWEP( + NEHotspotConfiguration::alloc(), + &ssid, + &passphrase, + false, + ) + } + } + Security::Wpa3 => { + // SAFETY: the init answers a configuration the caller owns, from + // a valid string the platform copies. + let configuration = unsafe { + NEHotspotConfiguration::initWithSSID(NEHotspotConfiguration::alloc(), &ssid) + }; + set_wpa3_passphrase(&configuration, &passphrase)?; + configuration + } + }; + // A Personal Hotspot shows its name only while its pane is open, so the + // system asks for the network by name rather than waiting to see it. + // SAFETY: the setter takes a plain flag on a configuration the caller + // owns. + unsafe { configuration.setHidden(true) }; + Ok(configuration) +} + +/// The WPA3 personal passphrase setter, which the typed bindings keep raw. +/// +/// The platform's `NEHotspotConfiguration` answers the setter as a raw +/// selector, so this asks for it and answers `Missing` where the platform +/// does not (R76). +fn set_wpa3_passphrase( + configuration: &NEHotspotConfiguration, + passphrase: &NSString, +) -> Result<(), Error> { + let selector = sel!(setWPA3PersonalPassphrase:); + if configuration.respondsToSelector(selector) { + // SAFETY: the platform answers the setter, and the passphrase is a + // valid string the platform copies. + let _answer: () = + unsafe { msg_send![configuration, setWPA3PersonalPassphrase: passphrase] }; + Ok(()) + } else { + Err(Error::Missing("setWPA3PersonalPassphrase:".to_owned())) + } +} + +/// The routing table dump's header, as the route parser reads it. +const _: () = assert!(std::mem::size_of::() == crate::route::HEADER); + +/// The joined network's interface. +const WIFI: &std::ffi::CStr = c"en0"; + +/// The gateway of the default route on the Wi-Fi interface, read from the +/// kernel's routing table, once the system reports one. +fn default_route_gateway() -> Option { + // SAFETY: the name is a valid string the call only reads. + let index = unsafe { libc::if_nametoindex(WIFI.as_ptr()) }; + let index = u16::try_from(index).ok().filter(|index| *index != 0)?; + let mut name = [ + libc::CTL_NET, + libc::PF_ROUTE, + 0, + libc::AF_INET, + libc::NET_RT_FLAGS, + libc::RTF_GATEWAY, + ]; + let depth = libc::c_uint::try_from(name.len()).ok()?; + let mut length: libc::size_t = 0; + // SAFETY: the name holds `depth` entries, and a null buffer asks only + // for the dump's length, which the call writes to `length`. + let asked = unsafe { + libc::sysctl( + name.as_mut_ptr(), + depth, + std::ptr::null_mut(), + &raw mut length, + std::ptr::null_mut(), + 0, + ) + }; + if asked != 0 { + return None; + } + let mut dump = vec![0u8; length]; + // SAFETY: the buffer holds `length` bytes, the call writes at most that + // many and answers the count it wrote in `length`. + let read = unsafe { + libc::sysctl( + name.as_mut_ptr(), + depth, + dump.as_mut_ptr().cast(), + &raw mut length, + std::ptr::null_mut(), + 0, + ) + }; + if read != 0 { + return None; + } + dump.truncate(length); + crate::route::default_gateway(&dump, index).map(IpAddr::V4) +} diff --git a/crates/connetto-peer-apple/src/platform/mod.rs b/crates/connetto-peer-apple/src/platform/mod.rs new file mode 100644 index 00000000..56f05d33 --- /dev/null +++ b/crates/connetto-peer-apple/src/platform/mod.rs @@ -0,0 +1,13 @@ +//! The Apple platform's frameworks, each behind its one lock (R76). + +pub(super) mod device; +#[expect( + unsafe_code, + reason = "the NetworkExtension calls objc2 marks unsafe and the libc reads of the Wi-Fi route, each sound as its SAFETY comment states" +)] +pub(super) mod hotspot; +#[expect( + unsafe_code, + reason = "the CoreBluetooth calls objc2 marks unsafe and the delegate's state, each sound under the one STANDING lock and the manager's serial queue" +)] +pub(super) mod peripheral; diff --git a/crates/connetto-peer-apple/src/platform/peripheral.rs b/crates/connetto-peer-apple/src/platform/peripheral.rs new file mode 100644 index 00000000..19de14ff --- /dev/null +++ b/crates/connetto-peer-apple/src/platform/peripheral.rs @@ -0,0 +1,679 @@ +//! The host's GATT service and beacon advertisement, over the process-wide +//! peripheral manager on its one serial queue (R76). + +use std::collections::{HashMap, VecDeque}; +use std::sync::{LazyLock, mpsc}; +use std::time::Duration; + +use dispatch2::{DispatchQueue, DispatchRetained}; +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, ProtocolObject}; +use objc2::{AnyThread, Message, define_class, msg_send}; +use objc2_core_bluetooth::{ + CBATTError, CBATTRequest, CBAdvertisementDataServiceUUIDsKey, CBAttributePermissions, + CBCentral, CBCharacteristic, CBCharacteristicProperties, CBError, CBManager, + CBManagerAuthorization, CBManagerState, CBMutableCharacteristic, CBMutableService, + CBPeripheralManager, CBPeripheralManagerDelegate, CBPeripheralManagerOptionShowPowerAlertKey, + CBService, CBUUID, +}; +use objc2_foundation::{ + NSArray, NSData, NSDictionary, NSError, NSNumber, NSObject, NSObjectProtocol, NSString, NSUUID, +}; +use parking_lot::Mutex; + +use connetto_peer::{INBOX_UUID, OUTBOX_UUID, SERVICE_UUID}; + +use crate::bluetooth::{Event, PromptOutcome, State}; +use crate::{Error, HOP_WITHIN, key_from_uuid_text}; + +/// The bound one hop onto the manager's queue gets. +const HOP: Duration = HOP_WITHIN; + +/// What the manager's queue and its delegate stand at. +#[derive(Default)] +struct Standing { + /// The manager's dedicated queue. + queue: Option>, + /// The manager, alive for the process. + manager: Option>, + /// The manager's delegate, alive for the process. + delegate: Option>, + /// The service the centrals discover. + service: Option>, + /// The characteristic the centrals write to. + inbox: Option>, + /// The characteristic the centrals subscribe to. + outbox: Option>, + /// The platform's standing of the radio. + radio: Option, + /// The centrals subscribed to the outbox, by key. + centrals: HashMap>, + /// The frames the platform has not yet taken, by central's key. + held: HashMap>>, + /// The advertisement is live. + advertising: bool, + /// Where the service's registration with the platform stands. + registration: Registration, + /// The host wants the service and the advertisement. + wanted: bool, +} + +/// Where the service's registration with the platform stands. +#[derive(Clone, Copy, Default, PartialEq, Eq)] +enum Registration { + /// No service is registered or being added. + #[default] + Absent, + /// A service add is in flight on the platform's queue. + Adding, + /// The service is registered. + Registered, +} + +static STANDING: LazyLock> = LazyLock::new(|| Mutex::new(Standing::default())); +// SAFETY: every field is read and written only under the one STANDING lock, +// the manager and its objects are dispatched only to the manager's one +// serial queue, and the platform's retain and release are thread-safe. +unsafe impl Send for Standing {} +static EVENTS: Mutex> = Mutex::new(Vec::new()); + +/// The radio's standing. Before the manager exists the app's Bluetooth +/// authorization answers, and an app the user already allowed creates the +/// manager at once, without the power alert, to learn the radio's standing. +pub(crate) fn state() -> State { + let (exists, radio) = { + let standing = STANDING.lock(); + (standing.manager.is_some(), standing.radio) + }; + if !exists { + // SAFETY: the class property reads the app's standing and takes no + // argument. + let authorization = unsafe { CBManager::authorization_class() }; + if authorization != CBManagerAuthorization::AllowedAlways { + return State::NotPermitted; + } + ensure_manager(false, false); + return State::Off; + } + match radio { + Some(CBManagerState::PoweredOn) => State::Ready, + Some(CBManagerState::Unauthorized) => State::NotPermitted, + Some(CBManagerState::Unsupported) => State::Unsupported, + _ => State::Off, + } +} + +/// Run the platform's strongest action once. +#[expect( + clippy::unnecessary_wraps, + reason = "the unsupported platform's prompt refuses, and both answer the one signature lib.rs exposes" +)] +pub(crate) fn prompt() -> Result<(), Error> { + // A recreated manager re-shows the platform's power alert where the + // radio stands off. An existing manager whose radio is on has nothing to + // show. + let recreate = { + let standing = STANDING.lock(); + standing.manager.is_some() && standing.radio == Some(CBManagerState::PoweredOff) + }; + ensure_manager(recreate, true); + Ok(()) +} + +/// The last prompt's outcome, once it settles. +pub(crate) fn prompt_outcome() -> Option { + let standing = STANDING.lock(); + match standing.radio { + Some(CBManagerState::PoweredOn) => Some(PromptOutcome::NotAsked), + Some(CBManagerState::Unauthorized) => Some(PromptOutcome::Declined), + _ => None, + } +} + +/// Open the GATT service and begin its beacon advertisement. +pub(crate) fn start() -> Result<(), Error> { + ensure_manager(false, true); + hop(|standing| { + if standing.radio != Some(CBManagerState::PoweredOn) { + return Err(off_error()); + } + if standing.advertising { + return Ok(()); + } + standing.wanted = true; + match standing.registration { + Registration::Registered => begin_advertising(standing), + Registration::Absent => add_service(standing), + Registration::Adding => {} + } + // A pending add settles on the platform's queue and + // answers the advertisement itself. + Ok(()) + }) + .and_then(|answer| answer) +} + +/// Stop the advertisement and close the service. +pub(crate) fn stop() -> Result<(), Error> { + hop(|standing| { + let Some(manager) = standing.manager.as_ref() else { + return Ok(()); + }; + standing.wanted = false; + if standing.advertising { + // SAFETY: the manager is alive for the process, and the + // platform's stop answers nothing to return. + unsafe { manager.stopAdvertising() }; + } + if standing.registration == Registration::Registered { + // SAFETY: the manager is alive for the process, and the + // platform's removal answers nothing to return. The removal is + // done when the call returns, the platform reporting nothing + // after it. + unsafe { manager.removeAllServices() }; + standing.registration = Registration::Absent; + standing.service = None; + standing.inbox = None; + standing.outbox = None; + } + clear_subscribers(standing); + Ok(()) + }) + .and_then(|answer| answer) +} + +/// Take the events the service has reported since the last call. +pub(crate) fn poll() -> Vec { + std::mem::take(&mut *EVENTS.lock()) +} + +/// Send one outbox notification to a subscribed central. +pub(crate) fn notify(device: u64, bytes: &[u8]) -> Result<(), Error> { + let frame = bytes.to_vec(); + hop(move |standing| { + let (Some(manager), Some(outbox)) = (standing.manager.as_ref(), standing.outbox.as_ref()) + else { + return Err(not_connected()); + }; + let Some(central) = standing.centrals.get(&device) else { + return Err(not_connected()); + }; + // A frame queued behind held ones keeps its place, so the central + // reads them in order. + let queued = standing + .held + .get(&device) + .is_some_and(|held| !held.is_empty()); + if !queued && send_frame(manager, outbox, central, &frame) { + Ok(()) + } else { + standing.held.entry(device).or_default().push_back(frame); + Ok(()) + } + }) + .and_then(|answer| answer) +} + +/// Close the central's connection, which the platform's peripheral role +/// cannot cancel (R76). +pub(crate) fn disconnect(_device: u64) {} + +/// Create the manager, with the platform's power alert where `power_alert` +/// stands, or recreate it when `recreate` stands to show the alert again. +fn ensure_manager(recreate: bool, power_alert: bool) { + let mut standing = STANDING.lock(); + if standing.manager.is_some() && !recreate { + return; + } + standing.queue = None; + standing.manager = None; + standing.delegate = None; + standing.service = None; + standing.inbox = None; + standing.outbox = None; + standing.centrals.clear(); + standing.held.clear(); + standing.advertising = false; + standing.registration = Registration::Absent; + standing.wanted = false; + let queue = DispatchQueue::new("connetto-peer-apple", None); + let delegate = PeripheralDelegate::new(); + // SAFETY: the delegate and the queue are valid for the process, the + // options dictionary carries one valid key and value, and the init + // answers a manager the caller owns. + let manager = unsafe { + CBPeripheralManager::initWithDelegate_queue_options( + CBPeripheralManager::alloc(), + Some(ProtocolObject::from_ref(&*delegate)), + Some(&queue), + Some(&options(power_alert)), + ) + }; + standing.queue = Some(queue); + standing.delegate = Some(delegate); + standing.manager = Some(manager); +} + +/// The manager's options, the platform's power alert on where `power_alert` +/// stands. +fn options(power_alert: bool) -> Retained> { + let value = NSNumber::new_bool(power_alert); + // SAFETY: the platform's power-alert key is an immutable string the + // platform owns for the process. + let key: &NSString = unsafe { CBPeripheralManagerOptionShowPowerAlertKey }; + NSDictionary::from_slices(&[key], &[value.as_ref()]) +} + +/// The advertisement's data, the service's UUID alone (R76 decision 25). +fn advertise_data() -> Retained> { + let service_uuid = uuid(&SERVICE_UUID); + let uuid_array = NSArray::::from_slice(&[&*service_uuid]); + // SAFETY: the platform's service-UUIDs key is an immutable string the + // platform owns for the process. + let key: &NSString = unsafe { CBAdvertisementDataServiceUUIDsKey }; + NSDictionary::from_slices(&[key], &[uuid_array.as_ref()]) +} + +/// The platform's UUID for one of the peer's 16-byte constants. +fn uuid(bytes: &[u8; 16]) -> Retained { + // SAFETY: the bytes are a valid 16-byte UUID, so the platform answers a + // UUID the caller owns. + unsafe { CBUUID::UUIDWithData(&NSData::with_bytes(bytes)) } +} + +/// Begin the advertisement from the platform's queue. +fn begin_advertising(standing: &mut Standing) { + let Some(manager) = standing.manager.as_ref() else { + return; + }; + // SAFETY: the manager is alive for the process, the data carries the + // service's UUID alone, and the platform's start answers nothing to + // return. + unsafe { manager.startAdvertising(Some(&advertise_data())) }; +} + +/// Register the service on the platform's queue. +fn add_service(standing: &mut Standing) { + let Some(manager) = standing.manager.as_ref() else { + return; + }; + let inbox_uuid = uuid(&INBOX_UUID); + let outbox_uuid = uuid(&OUTBOX_UUID); + let service_uuid = uuid(&SERVICE_UUID); + // SAFETY: the UUID is the peer's inbox constant, the properties and + // permissions are valid for a writable characteristic, and the init + // answers an object the caller owns. + let inbox = unsafe { + CBMutableCharacteristic::initWithType_properties_value_permissions( + CBMutableCharacteristic::alloc(), + &inbox_uuid, + CBCharacteristicProperties::Write, + None, + CBAttributePermissions::Writeable, + ) + }; + // SAFETY: the UUID is the peer's outbox constant, the properties and + // permissions are valid for a readable, notifying characteristic, and the + // init answers an object the caller owns. + let outbox = unsafe { + CBMutableCharacteristic::initWithType_properties_value_permissions( + CBMutableCharacteristic::alloc(), + &outbox_uuid, + CBCharacteristicProperties::Read | CBCharacteristicProperties::Notify, + None, + CBAttributePermissions::Readable, + ) + }; + // SAFETY: the UUID is the peer's service constant, and the init answers a + // service the caller owns. + let service = unsafe { + CBMutableService::initWithType_primary(CBMutableService::alloc(), &service_uuid, true) + }; + let characteristics = NSArray::::from_slice(&[&inbox, &outbox]); + // SAFETY: the platform's set answers nothing to return, and the array + // holds the service's own characteristics. + unsafe { service.setCharacteristics(Some(&characteristics)) }; + // SAFETY: the manager is alive for the process, the service is a valid + // object the platform retains, and the platform's add answers nothing to + // return. + unsafe { manager.addService(&service) }; + standing.inbox = Some(inbox); + standing.outbox = Some(outbox); + standing.service = Some(service); + standing.registration = Registration::Adding; +} + +/// Send one frame to one subscribed central, true when the platform took it. +fn send_frame( + manager: &CBPeripheralManager, + outbox: &CBMutableCharacteristic, + central: &CBCentral, + frame: &[u8], +) -> bool { + let data = NSData::with_bytes(frame); + let centrals = NSArray::from_slice(&[central]); + // SAFETY: the manager is alive for the process, the frame is valid data + // the platform copies, and the platform's send answers a plain bool. + unsafe { + manager.updateValue_forCharacteristic_onSubscribedCentrals(&data, outbox, Some(¢rals)) + } +} + +/// Clear the centrals and their held frames, reporting their endings. +fn clear_subscribers(standing: &mut Standing) { + for device in standing.centrals.keys().copied() { + EVENTS.lock().push(Event::Disconnected { device }); + } + standing.centrals.clear(); + standing.held.clear(); + standing.advertising = false; +} + +/// The central's key, the first eight bytes of its identifier. +fn device_key(identifier: &NSUUID) -> u64 { + key_from_uuid_text(&identifier.to_string()).unwrap_or(u64::MAX) +} + +/// Whether `characteristic` is the one `uuid` names, by its UUID's bytes, +/// since the platform may hand a delegate another object for the same +/// characteristic. +fn is_characteristic(characteristic: &CBCharacteristic, uuid: &[u8; 16]) -> bool { + // SAFETY: the platform passes the characteristic for the duration of the + // call, and its UUID is a valid object it owns. + let id = unsafe { characteristic.UUID() }; + // SAFETY: the UUID answers its bytes as a valid data object. + let bytes = unsafe { id.data() }; + bytes.to_vec() == uuid +} + +/// The platform's answer for a radio that is not on. +fn off_error() -> Error { + Error::Apple { + domain: "CoreBluetooth".to_owned(), + code: CBError(2).0, + description: "the radio is not on".to_owned(), + } +} + +/// The platform's answer for a central that is not subscribed. +fn not_connected() -> Error { + Error::Apple { + domain: "CoreBluetooth".to_owned(), + code: CBError(3).0, + description: "the central is not subscribed".to_owned(), + } +} + +/// What a framework error stands for. +fn describe(error: &NSError) -> String { + format!("{} {}", error.domain(), error.localizedDescription()) +} + +/// Run `work` on the manager's queue and take its answer. +fn hop( + work: impl FnOnce(&mut Standing) -> T + Send + 'static, +) -> Result { + let queue = STANDING.lock().queue.clone().ok_or(Error::Unsupported)?; + let (tx, rx) = mpsc::channel(); + queue.exec_async(move || { + let answer = work(&mut STANDING.lock()); + let _ = tx.send(answer); + }); + rx.recv_timeout(HOP).map_err(|_| Error::TimedOut(HOP)) +} + +define_class!( + #[derive(Debug)] + #[unsafe(super(NSObject))] + #[thread_kind = AnyThread] + pub struct PeripheralDelegate; + + unsafe impl NSObjectProtocol for PeripheralDelegate {} + + unsafe impl CBPeripheralManagerDelegate for PeripheralDelegate { + #[unsafe(method(peripheralManagerDidUpdateState:))] + fn peripheral_manager_did_update_state(&self, peripheral: &CBPeripheralManager) { + // SAFETY: the manager is alive for the process, and the + // platform's state answer is a plain enum. + let state = unsafe { peripheral.state() }; + let mut standing = STANDING.lock(); + standing.radio = Some(state); + if state != CBManagerState::PoweredOn { + // The platform ends every central's subscription with the + // radio, and clears its local database with the radio. + clear_subscribers(&mut standing); + standing.service = None; + standing.inbox = None; + standing.outbox = None; + standing.registration = Registration::Absent; + standing.wanted = false; + } + } + + #[unsafe(method(peripheralManagerDidStartAdvertising:error:))] + fn peripheral_manager_did_start_advertising_error( + &self, + _peripheral: &CBPeripheralManager, + error: Option<&NSError>, + ) { + let mut standing = STANDING.lock(); + match error { + None => standing.advertising = true, + Some(error) => { + standing.advertising = false; + EVENTS.lock().push(Event::Failed(describe(error))); + } + } + } + + #[unsafe(method(peripheralManager:didAddService:error:))] + fn peripheral_manager_did_add_service_error( + &self, + _peripheral: &CBPeripheralManager, + _service: &CBService, + error: Option<&NSError>, + ) { + let mut standing = STANDING.lock(); + standing.registration = Registration::Absent; + match error { + // A service added after the host stopped wanting it is + // removed again. + None if !standing.wanted => { + if let Some(manager) = standing.manager.as_ref() { + // SAFETY: the manager is alive for the process, and + // the platform's removal answers nothing to return. + unsafe { manager.removeAllServices() }; + } + standing.service = None; + standing.inbox = None; + standing.outbox = None; + } + None => { + standing.registration = Registration::Registered; + if !standing.advertising { + begin_advertising(&mut standing); + } + } + Some(error) => { + standing.wanted = false; + standing.service = None; + standing.inbox = None; + standing.outbox = None; + EVENTS.lock().push(Event::Failed(describe(error))); + } + } + } + + #[unsafe(method(peripheralManager:central:didSubscribeToCharacteristic:))] + fn peripheral_manager_central_did_subscribe_to_characteristic( + &self, + _peripheral: &CBPeripheralManager, + central: &CBCentral, + characteristic: &CBCharacteristic, + ) { + if !is_characteristic(characteristic, &OUTBOX_UUID) { + return; + } + // SAFETY: the platform passes the central's identifier for the + // duration of this call, a valid NSUUID copied before the call + // returns. + let identifier = unsafe { central.identifier() }; + let device = device_key(&identifier); + // SAFETY: the platform passes the central's frame length for the + // duration of this call, a plain size. + let mtu = + u16::try_from(unsafe { central.maximumUpdateValueLength() }).unwrap_or(u16::MAX); + let mut standing = STANDING.lock(); + let central = central.retain(); + standing.centrals.insert(device, central); + EVENTS.lock().push(Event::Connected { device, mtu }); + } + + #[unsafe(method(peripheralManager:central:didUnsubscribeFromCharacteristic:))] + fn peripheral_manager_central_did_unsubscribe_from_characteristic( + &self, + _peripheral: &CBPeripheralManager, + central: &CBCentral, + characteristic: &CBCharacteristic, + ) { + if !is_characteristic(characteristic, &OUTBOX_UUID) { + return; + } + // SAFETY: the platform passes the central's identifier for the + // duration of this call, a valid NSUUID copied before the call + // returns. + let identifier = unsafe { central.identifier() }; + let device = device_key(&identifier); + let mut standing = STANDING.lock(); + if standing.centrals.remove(&device).is_some() { + standing.held.remove(&device); + EVENTS.lock().push(Event::Disconnected { device }); + } + } + + #[unsafe(method(peripheralManager:didReceiveReadRequest:))] + fn peripheral_manager_did_receive_read_request( + &self, + peripheral: &CBPeripheralManager, + request: &CBATTRequest, + ) { + // SAFETY: the platform passes the request's characteristic for + // the duration of this call, a valid object. + let characteristic = unsafe { request.characteristic() }; + let result = if is_characteristic(&characteristic, &OUTBOX_UUID) { + // SAFETY: the platform passes the request for the duration of + // this call, and the outbox read stands answered with an + // empty value. + unsafe { request.setValue(None) }; + CBATTError::Success + } else if is_characteristic(&characteristic, &INBOX_UUID) { + CBATTError::ReadNotPermitted + } else { + CBATTError::RequestNotSupported + }; + // SAFETY: the manager is alive for the process, and the platform + // answers the response with nothing to return. + unsafe { peripheral.respondToRequest_withResult(request, result) }; + } + + /// The platform hands one write, or the parts of a long write in + /// offset order, as one array, and waits for one answer on its first + /// request, so the parts become one chunk and the answer covers them + /// all. + #[unsafe(method(peripheralManager:didReceiveWriteRequests:))] + fn peripheral_manager_did_receive_write_requests( + &self, + peripheral: &CBPeripheralManager, + requests: &NSArray, + ) { + let Some(first) = requests.firstObject() else { + return; + }; + let mut bytes = Vec::new(); + let mut device = None; + let mut result = CBATTError::Success; + for index in 0..requests.count() { + let request = requests.objectAtIndex(index); + // SAFETY: the platform passes the request's characteristic + // for the duration of this call, a valid object. + let characteristic = unsafe { request.characteristic() }; + if !is_characteristic(&characteristic, &INBOX_UUID) { + result = CBATTError::WriteNotPermitted; + break; + } + // SAFETY: the platform passes the request's central for the + // duration of this call, a valid object. + let central = unsafe { request.central() }; + // SAFETY: the platform passes the central's identifier for + // the duration of this call, a valid NSUUID. + let identifier = unsafe { central.identifier() }; + device = Some(device_key(&identifier)); + // SAFETY: the platform passes the written data for the + // duration of this call, copied before the call returns, and + // an empty write answers none. + if let Some(value) = unsafe { request.value() } { + bytes.extend_from_slice(&value.to_vec()); + } + } + if result == CBATTError::Success + && let Some(device) = device + { + EVENTS.lock().push(Event::Chunk { device, bytes }); + } + // SAFETY: the manager is alive for the process, the first request + // stands for the call's duration, and the platform answers the + // response with nothing to return. + unsafe { peripheral.respondToRequest_withResult(&first, result) }; + } + + #[unsafe(method(peripheralManagerIsReadyToUpdateSubscribers:))] + fn peripheral_manager_is_ready_to_update_subscribers( + &self, + peripheral: &CBPeripheralManager, + ) { + let (held, outbox, centrals) = { + let mut standing = STANDING.lock(); + ( + std::mem::take(&mut standing.held), + standing.outbox.clone(), + standing.centrals.clone(), + ) + }; + let Some(outbox) = outbox else { + return; + }; + for (device, mut frames) in held { + let Some(central) = centrals.get(&device) else { + continue; + }; + // The frames go in order until the platform's queue fills, + // and every frame not sent stays held, ahead of any held + // since the take. + while let Some(frame) = frames.pop_front() { + if !send_frame(peripheral, &outbox, central, &frame) { + frames.push_front(frame); + break; + } + } + if !frames.is_empty() { + let mut standing = STANDING.lock(); + if let Some(later) = standing.held.remove(&device) { + frames.extend(later); + } + standing.held.insert(device, frames); + } + } + } + } +); + +impl PeripheralDelegate { + /// The delegate's one instance. + fn new() -> Retained { + let this = Self::alloc().set_ivars(()); + // SAFETY: the definition registers the class, the alloc answered an + // uninit object of this class, and NSObject's init answers the + // initialized object the caller owns. + unsafe { msg_send![super(this), init] } + } +} diff --git a/crates/connetto-peer-apple/src/route.rs b/crates/connetto-peer-apple/src/route.rs new file mode 100644 index 00000000..7a71e9ef --- /dev/null +++ b/crates/connetto-peer-apple/src/route.rs @@ -0,0 +1,168 @@ +//! The default gateway read from the kernel's routing table dump, the bytes +//! `sysctl` answers for `CTL_NET`, `PF_ROUTE`, `NET_RT_FLAGS` and +//! `RTF_GATEWAY` on Apple systems (R76). + +use std::net::Ipv4Addr; + +/// The length of Apple's `rt_msghdr`, the header before each route's +/// addresses. +pub(crate) const HEADER: usize = 92; +/// The route is up. +const RTF_UP: u32 = 0x1; +/// The route goes through a gateway. +const RTF_GATEWAY: u32 = 0x2; +/// The route's destination address follows the header. +const RTA_DST: u32 = 0x1; +/// The route's gateway address follows the destination. +const RTA_GATEWAY: u32 = 0x2; +/// The address family of IP version 4. +const AF_INET: u8 = 2; + +/// The gateway of the up default route over IP version 4 on the interface `index` in +/// `dump`, `None` when the dump carries none or does not parse. +pub(crate) fn default_gateway(dump: &[u8], index: u16) -> Option { + let mut rest = dump; + while rest.len() >= HEADER { + let length = usize::from(u16::from_ne_bytes([rest[0], rest[1]])); + if length < HEADER || length > rest.len() { + return None; + } + let (message, next) = rest.split_at(length); + rest = next; + let route_index = u16::from_ne_bytes([message[4], message[5]]); + let flags = u32::from_ne_bytes(message[8..12].try_into().ok()?); + let addresses = u32::from_ne_bytes(message[12..16].try_into().ok()?); + if route_index != index + || flags & (RTF_UP | RTF_GATEWAY) != RTF_UP | RTF_GATEWAY + || addresses & (RTA_DST | RTA_GATEWAY) != RTA_DST | RTA_GATEWAY + { + continue; + } + let body = &message[HEADER..]; + let (destination, body) = sockaddr(body)?; + let (gateway, _) = sockaddr(body)?; + if inet(destination).unwrap_or(Ipv4Addr::UNSPECIFIED) != Ipv4Addr::UNSPECIFIED { + continue; + } + if let Some(gateway) = inet(gateway) { + return Some(gateway); + } + } + None +} + +/// The next address in a route message and the bytes after it, its length +/// rounded up to four bytes and an empty one taking four. +fn sockaddr(body: &[u8]) -> Option<(&[u8], &[u8])> { + let length = usize::from(*body.first()?); + let taken = length.max(1).div_ceil(4) * 4; + if body.len() < taken { + return None; + } + Some((&body[..length], &body[taken..])) +} + +/// The version 4 address of `sockaddr`, `None` for another family or a short +/// one. +fn inet(sockaddr: &[u8]) -> Option { + if sockaddr.len() < 8 || sockaddr[1] != AF_INET { + return None; + } + Some(Ipv4Addr::new( + sockaddr[4], + sockaddr[5], + sockaddr[6], + sockaddr[7], + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One route message, Apple's header then the destination and gateway + /// addresses, each padded to four bytes. + fn message(index: u16, flags: u32, destination: &[u8], gateway: &[u8]) -> Vec { + let mut body = Vec::new(); + for address in [destination, gateway] { + body.extend_from_slice(address); + let padded = address.len().max(1).div_ceil(4) * 4; + body.resize(body.len() + padded - address.len(), 0); + } + let mut header = vec![0u8; HEADER]; + let length = u16::try_from(HEADER + body.len()).unwrap(); + header[0..2].copy_from_slice(&length.to_ne_bytes()); + header[2] = 5; + header[3] = 4; + header[4..6].copy_from_slice(&index.to_ne_bytes()); + header[8..12].copy_from_slice(&flags.to_ne_bytes()); + header[12..16].copy_from_slice(&(RTA_DST | RTA_GATEWAY).to_ne_bytes()); + header.extend_from_slice(&body); + header + } + + fn inet(address: [u8; 4]) -> Vec { + let mut sockaddr = vec![16, AF_INET, 0, 0]; + sockaddr.extend_from_slice(&address); + sockaddr.resize(16, 0); + sockaddr + } + + #[test] + fn the_default_route_on_the_interface_answers_its_gateway() { + let mut dump = message( + 3, + RTF_UP | RTF_GATEWAY, + &inet([10, 0, 0, 0]), + &inet([10, 0, 0, 9]), + ); + dump.extend(message( + 4, + RTF_UP | RTF_GATEWAY, + &inet([0, 0, 0, 0]), + &inet([172, 20, 10, 1]), + )); + assert_eq!( + default_gateway(&dump, 4), + Some(Ipv4Addr::new(172, 20, 10, 1)) + ); + } + + #[test] + fn a_default_route_with_an_empty_destination_counts() { + let dump = message(4, RTF_UP | RTF_GATEWAY, &[0], &inet([192, 168, 43, 1])); + assert_eq!( + default_gateway(&dump, 4), + Some(Ipv4Addr::new(192, 168, 43, 1)) + ); + } + + #[test] + fn another_interface_or_a_down_route_answers_none() { + let mut dump = message( + 7, + RTF_UP | RTF_GATEWAY, + &inet([0, 0, 0, 0]), + &inet([10, 9, 0, 1]), + ); + dump.extend(message( + 4, + RTF_GATEWAY, + &inet([0, 0, 0, 0]), + &inet([172, 20, 10, 1]), + )); + assert_eq!(default_gateway(&dump, 4), None); + } + + #[test] + fn a_truncated_dump_answers_none() { + let dump = message( + 4, + RTF_UP | RTF_GATEWAY, + &inet([0, 0, 0, 0]), + &inet([172, 20, 10, 1]), + ); + assert_eq!(default_gateway(&dump[..HEADER + 10], 4), None); + assert_eq!(default_gateway(&dump[..40], 4), None); + } +} diff --git a/crates/connetto-peer-apple/src/unsupported.rs b/crates/connetto-peer-apple/src/unsupported.rs new file mode 100644 index 00000000..e0878035 --- /dev/null +++ b/crates/connetto-peer-apple/src/unsupported.rs @@ -0,0 +1,98 @@ +//! The non-Apple platform: every framework call answers its own refusal. + +/// The device side, absent off Apple. +pub(super) mod device { + use super::super::Idiom; + + /// The device's user-set name, absent off Apple. + pub(crate) fn device_name() -> Option { + None + } + + /// The device's idiom, `Other` off Apple. + pub(crate) fn device_idiom() -> Idiom { + Idiom::Other + } +} + +/// The Bluetooth side, absent off Apple. +pub(super) mod peripheral { + use super::super::{ + Error, + bluetooth::{Event, PromptOutcome, State}, + }; + + /// The radio's standing, unsupported off Apple. + pub(crate) fn state() -> State { + State::Unsupported + } + + /// The strongest action, which has nothing to do off Apple. + pub(crate) fn prompt() -> Result<(), Error> { + Err(Error::Unsupported) + } + + /// The last prompt's outcome, never settled off Apple. + pub(crate) fn prompt_outcome() -> Option { + None + } + + /// The GATT service, which stands nowhere off Apple. + pub(crate) fn start() -> Result<(), Error> { + Err(Error::Unsupported) + } + + /// The GATT service's close, which stands ready off Apple. + #[expect( + clippy::unnecessary_wraps, + reason = "one signature on every target, and only iOS can fail" + )] + pub(crate) fn stop() -> Result<(), Error> { + Ok(()) + } + + /// The service's events, never any off Apple. + pub(crate) fn poll() -> Vec { + Vec::new() + } + + /// The service's send, which reaches no central off Apple. + pub(crate) fn notify(_device: u64, _bytes: &[u8]) -> Result<(), Error> { + Err(Error::Unsupported) + } + + /// The central's close, which stands ready off Apple. + pub(crate) fn disconnect(_device: u64) {} +} + +/// The hotspot side, absent off Apple. +pub(super) mod hotspot { + use super::super::{ + Error, + hotspot::{Joined, Joining, Security}, + }; + + /// The system's join, which stands nowhere off Apple. + pub(crate) fn join(_ssid: &str, _passphrase: &str, _security: Security) -> Result<(), Error> { + Err(Error::Unsupported) + } + + /// The system's answer, never any off Apple. + pub(crate) fn completion() -> Option> { + None + } + + /// The system's release, which stands ready off Apple. + #[expect( + clippy::unnecessary_wraps, + reason = "one signature on every target, and only iOS can fail" + )] + pub(crate) fn leave(_ssid: &str) -> Result<(), Error> { + Ok(()) + } + + /// The joined network, never any off Apple. + pub(crate) fn joined() -> Option { + None + } +} diff --git a/crates/connetto-test-harness/ios/Tapper.xcodeproj/project.pbxproj b/crates/connetto-test-harness/ios/Tapper.xcodeproj/project.pbxproj new file mode 100644 index 00000000..b5a2ce6d --- /dev/null +++ b/crates/connetto-test-harness/ios/Tapper.xcodeproj/project.pbxproj @@ -0,0 +1,258 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 9A0000000000000000000001 /* Tapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A0000000000000000000003 /* Tapper.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 9A0000000000000000000002 /* Tapper.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tapper.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 9A0000000000000000000003 /* Tapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tapper.swift; sourceTree = ""; }; + 9A0000000000000000000004 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 9A0000000000000000000005 = { + isa = PBXGroup; + children = ( + 9A0000000000000000000006 /* Tapper */, + 9A0000000000000000000007 /* Products */, + ); + sourceTree = ""; + }; + 9A0000000000000000000006 /* Tapper */ = { + isa = PBXGroup; + children = ( + 9A0000000000000000000003 /* Tapper.swift */, + 9A0000000000000000000004 /* Info.plist */, + ); + path = Tapper; + sourceTree = ""; + }; + 9A0000000000000000000007 /* Products */ = { + isa = PBXGroup; + children = ( + 9A0000000000000000000002 /* Tapper.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 9A0000000000000000000008 /* Tapper */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9A000000000000000000000B /* Build configuration list for PBXNativeTarget "Tapper" */; + buildPhases = ( + 9A000000000000000000000A /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Tapper; + productName = Tapper; + productReference = 9A0000000000000000000002 /* Tapper.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 9A0000000000000000000009 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2700; + LastUpgradeCheck = 2700; + TargetAttributes = { + 9A0000000000000000000008 = { + CreatedOnToolsVersion = 27.0; + }; + }; + }; + buildConfigurationList = 9A000000000000000000000C /* Build configuration list for PBXProject "Tapper" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 9A0000000000000000000005; + productRefGroup = 9A0000000000000000000007 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 9A0000000000000000000008 /* Tapper */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 9A000000000000000000000A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9A0000000000000000000001 /* Tapper.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 9A000000000000000000000D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 9A000000000000000000000E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 9A000000000000000000000F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = Tapper/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.connetto.dioxusdemo.tapper; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = ""; + }; + name = Debug; + }; + 9A0000000000000000000010 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = Tapper/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.connetto.dioxusdemo.tapper; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = ""; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 9A000000000000000000000B /* Build configuration list for PBXNativeTarget "Tapper" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9A000000000000000000000F /* Debug */, + 9A0000000000000000000010 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 9A000000000000000000000C /* Build configuration list for PBXProject "Tapper" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9A000000000000000000000D /* Debug */, + 9A000000000000000000000E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 9A0000000000000000000009 /* Project object */; +} diff --git a/crates/connetto-test-harness/ios/Tapper.xcodeproj/xcshareddata/xcschemes/Tapper.xcscheme b/crates/connetto-test-harness/ios/Tapper.xcodeproj/xcshareddata/xcschemes/Tapper.xcscheme new file mode 100644 index 00000000..d4a27a3c --- /dev/null +++ b/crates/connetto-test-harness/ios/Tapper.xcodeproj/xcshareddata/xcschemes/Tapper.xcscheme @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + diff --git a/crates/connetto-test-harness/ios/Tapper/Info.plist b/crates/connetto-test-harness/ios/Tapper/Info.plist new file mode 100644 index 00000000..6c6c23c4 --- /dev/null +++ b/crates/connetto-test-harness/ios/Tapper/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/crates/connetto-test-harness/ios/Tapper/Tapper.swift b/crates/connetto-test-harness/ios/Tapper/Tapper.swift new file mode 100644 index 00000000..4913ad7d --- /dev/null +++ b/crates/connetto-test-harness/ios/Tapper/Tapper.swift @@ -0,0 +1,198 @@ +import XCTest + +/// One error the tapper names in the test log the proof keeps. +enum TapperError: LocalizedError { + case action(String) + + var errorDescription: String? { + switch self { + case .action(let message): message + } + } +} + +/// Taps a system alert with a known button, or switches Personal Hotspot in +/// Settings, one action per start. The action and its arguments arrive as +/// `TEST_RUNNER_` environment variables the proof writes into the run's +/// xctestrun file, since a shell environment does not cross from the Mac to +/// the device. +final class TapperTests: XCTestCase { + /// The demo the tapper returns to after a Settings action. + static let demoBundle = "dev.connetto.dioxusdemo" + + func testAction() throws { + let environment = ProcessInfo.processInfo.environment + let bound = bounded(environment["TEST_RUNNER_TIMEOUT"], default: 90) + switch environment["TEST_RUNNER_ACTION"] ?? "" { + case "tap-alert": + let labels = labels(environment["TEST_RUNNER_LABELS"]) + guard !labels.isEmpty else { + throw TapperError.action("TEST_RUNNER_LABELS names no button") + } + try tapAlert(labels: labels, bound: bound) + case "hotspot-on", "hotspot-off": + try personalHotspot(on: environment["TEST_RUNNER_ACTION"] == "hotspot-on", bound: bound) + default: + throw TapperError.action( + "no TEST_RUNNER_ACTION, or an unknown one: \(environment["TEST_RUNNER_ACTION"] ?? "")" + ) + } + } + + /// The bound in seconds, clamped so a bad value cannot hang the run. + private func bounded(_ raw: String?, default defaultValue: TimeInterval) -> TimeInterval { + let value = raw.flatMap(TimeInterval.init) ?? defaultValue + return min(max(value, 5), 600) + } + + private func labels(_ raw: String?) -> [String] { + (raw ?? "") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + /// Tap an alert with a known button as long as such an alert shows, so a + /// join and a local-network prompt in one join are both cleared. The + /// action holds a floor, since it can start before its trigger, and + /// quits on the silence after the last alert, since a prompt appears + /// within a couple of seconds of its trigger. + private func tapAlert(labels: [String], bound: TimeInterval) throws { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let deadline = Date().addingTimeInterval(bound) + let floor = min(bound, 10) + let quiet: TimeInterval = 8 + let started = Date() + var lastAlert = Date.distantPast + var taps = 0 + var seen = "no alert" + while Date() < deadline { + let alert = springboard.alerts.element + if alert.exists { + lastAlert = Date() + // The alert can go at any point between the look and the tap, + // answered by its own timeout, by the system or by a person + // at the phone, which leaves nothing to tap and nothing wrong. + let options = XCTExpectedFailure.Options() + options.isStrict = false + XCTExpectFailure("the alert went before its tap", options: options) { + // The label is read before the tap, since the alert is + // gone after it. + let label = alert.label + seen = "the alert \(label) with \(alert.buttons.count) button(s)" + if let match = labels.first(where: { alert.buttons[$0].firstMatch.exists }) { + alert.buttons[match].firstMatch.tap() + taps += 1 + print("tapped \(match) on the alert: \(label)") + } + } + } + Thread.sleep(forTimeInterval: 0.5) + if Date().timeIntervalSince(started) > floor, Date().timeIntervalSince(lastAlert) > quiet { + break + } + } + if springboard.alerts.element.exists { + throw TapperError.action("an alert stayed after \(Int(bound))s: \(seen)") + } + print(taps == 0 ? "no alert to tap" : "tapped \(taps) alert(s)") + } + + /// The Personal Hotspot row's title in the languages the proof's phones + /// run. + static let paneTitles = ["Hotspot personale", "Personal Hotspot"] + /// The pane's switch that lets other devices join, in the same languages. + static let joinSwitches = ["Consenti agli altri di accedere", "Allow Others to Join"] + + /// Switch Personal Hotspot on or off in Settings and return to the demo. + /// A switch whose value reads as the wanted state is left alone, and the + /// value is read again after a tap. + private func personalHotspot(on: Bool, bound: TimeInterval) throws { + let settings = XCUIApplication(bundleIdentifier: "com.apple.Preferences") + let toggle = try openPersonalHotspotPane(settings, bound: bound) + let wanted = on ? "1" : "0" + if let value = toggle.value as? String, value == wanted { + print("Personal Hotspot already \(on ? "on" : "off")") + } else { + let knob = toggle.switches.firstMatch + (knob.exists && knob.isHittable ? knob : toggle).tap() + Thread.sleep(forTimeInterval: 1) + if let value = toggle.value as? String, value != wanted { + throw TapperError.action("the switch reads \(value) after the tap, wanted \(wanted)") + } + print("Personal Hotspot switched \(on ? "on" : "off")") + } + try returnToDemo() + } + + /// Open the pane from the row that names it, on Settings' top list or + /// else among the search results, and answer its join switch, whose + /// appearance is what says the pane opened. Only a hittable row counts, + /// since the top list's row stays in the hierarchy under the search + /// results. + private func openPersonalHotspotPane(_ settings: XCUIApplication, bound: TimeInterval) throws -> XCUIElement { + let deadline = Date().addingTimeInterval(min(bound, 60)) + let join = settings.switches + .matching(NSPredicate(format: "identifier IN %@", Self.joinSwitches)) + .firstMatch + for title in Self.paneTitles { + settings.terminate() + settings.launch() + guard settings.windows.firstMatch.waitForExistence(timeout: 10) else { + throw TapperError.action("Settings never opened") + } + var row = hittableRow(settings, title: title, within: 5) + if row == nil { + let search = settings.searchFields.firstMatch + guard search.waitForExistence(timeout: 10) else { + throw TapperError.action("no search field in Settings") + } + search.tap() + search.typeText(title) + row = hittableRow(settings, title: title, within: 5) + } + if let row { + row.tap() + if join.waitForExistence(timeout: 10) { + print("the \(title) pane opened") + return join + } + } + print("no row named \(title) opened its pane, the screen shows:\n\(settings.debugDescription)") + guard Date() < deadline else { break } + } + throw TapperError.action("the Personal Hotspot pane never opened, searched for \(Self.paneTitles)") + } + + /// The first hittable cell holding `title`, polled until `within` passes. + private func hittableRow(_ settings: XCUIApplication, title: String, within: TimeInterval) -> XCUIElement? { + let deadline = Date().addingTimeInterval(within) + repeat { + let rows = settings.cells + .containing(NSPredicate(format: "label ==[c] %@", title)) + .allElementsBoundByIndex + if let row = rows.first(where: { $0.exists && $0.isHittable }) { + return row + } + Thread.sleep(forTimeInterval: 0.5) + } while Date() < deadline + return nil + } + + /// Bring the demo back to the front, where the proof drives its page. + /// It is activated, never launched, since a launch would restart it + /// without the environment the proof started it with, and a demo that is + /// not running stays so. + private func returnToDemo() throws { + let demo = XCUIApplication(bundleIdentifier: Self.demoBundle) + guard demo.state != .notRunning else { + print("the demo is not running, so the tapper stays in Settings") + return + } + demo.activate() + guard demo.wait(for: .runningForeground, timeout: 10) else { + throw TapperError.action("the demo would not come to the front") + } + print("the demo is in front") + } +} diff --git a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs index f4e856fb..6c4c698d 100644 --- a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs +++ b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs @@ -64,6 +64,8 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow, bail}; use connetto_test_harness::demo::{order_count, wait_for_count}; use connetto_test_harness::inspector::{PageSession, list_pages}; +use connetto_test_harness::peer_devices::{own_identity, read_hotspot_offer, wait_link_ends}; +use connetto_test_harness::peer_proof::{JoinReport, commit, summary}; use connetto_test_harness::stack::{ demo_has_device_identity, demo_mobile_features, now_millis, repo_path, }; @@ -112,9 +114,6 @@ const DENY_BUTTONS: [&str; 6] = [ "Ablehnen", "Nicht zulassen", ]; -/// The silence a link gets to end after the phones leave the hotspot, on -/// either side. -const LINK_END_BOUND: Duration = Duration::from_secs(120); /// The beacon's service-data UUID (R76), required in the host's dump. const BEACON_SERVICE_UUID: &str = "a9952637-85d5-4071-9ed8-c28bd7ba670c"; /// The characteristic the joiner writes to (R76). @@ -452,36 +451,6 @@ async fn recheck_after_time_away( device.screenshot(evidence, "unlocked").await } -/// The offer a hosting phone shows, its lines read off the page. -struct HotspotOfferLines { - /// The hotspot's ssid. - ssid: String, - /// The hotspot's passphrase. - passphrase: String, - /// The host's peer port. - port: u16, - /// The offer as one `WIFI:` payload, where the page shows it. - payload: Option, -} - -/// The offer a hosting phone shows, its lines read off the page, bounded. -async fn read_hotspot_offer(app: &mut PageSession) -> Result { - app.wait_for_text("hotspot ssid: ", Duration::from_secs(45)) - .await - .context("the host never showed its offer")?; - let deadline = Instant::now() + Duration::from_secs(45); - loop { - let page = app.page_text().await?; - if let Some(offer) = parse_offer_lines(&page) { - return Ok(offer); - } - if Instant::now() >= deadline { - bail!("the offer's lines never finished showing, the page shows:\n{page}"); - } - sleep(Duration::from_millis(500)).await; - } -} - /// What every join of a run shares: the build, the stack, the evidence /// folder and the unlock. struct PeerRun<'a> { @@ -522,7 +491,7 @@ async fn joins_with_summary( let failed: Vec<&str> = joins? .iter() .filter(|report| report.outcome.is_err()) - .map(|report| report.join.name()) + .map(|report| report.name) .collect(); if !failed.is_empty() { bail!("the {} join failed", failed.join(", ")); @@ -566,14 +535,6 @@ impl JoinBy { } } -/// One join's result, for the run's summary. -struct JoinReport { - join: JoinBy, - /// How long the join ran, or `None` for a join never started. - took: Option, - outcome: Result<()>, -} - /// What the peer proof ran, apart from the second phone's restore after it, /// so a failed restore never hides the joins that ran. struct PeerOutcome { @@ -748,7 +709,7 @@ async fn run_peer_proof( for &join in joins { if let Some(previous) = unclean { reports.push(JoinReport { - join, + name: join.name(), took: None, outcome: Err(anyhow!( "the {} join's cleanup failed, so the phones were not at rest", @@ -783,7 +744,7 @@ async fn run_peer_proof( .await; } reports.push(JoinReport { - join, + name: join.name(), took: Some(started.elapsed()), outcome, }); @@ -1001,29 +962,6 @@ async fn host_and_link( Ok(()) } -/// The page's own peer identity, `account/key prefix`, from its listening -/// line, which the wait polls until the line carries it. -async fn own_identity(app: &mut PageSession) -> Result { - // The peer panel updates the listening line on its own poll, which the - // device label does not wait for, so the wait polls the line itself. - let deadline = Instant::now() + Duration::from_secs(30); - loop { - let page = app.page_text().await?; - if let Some(identity) = page - .lines() - .find_map(|line| line.strip_prefix("peer: listening on ")) - .and_then(|rest| rest.split_once(" as ")) - .map(|(_, identity)| identity.trim().to_owned()) - { - return Ok(identity); - } - if Instant::now() >= deadline { - bail!("the page names no peer identity"); - } - sleep(Duration::from_millis(500)).await; - } -} - /// Sign the joiner in as its own account and wait for it to connect and /// certify, leaving its app page open. async fn sign_in_joiner( @@ -1052,23 +990,6 @@ async fn sign_in_joiner( Ok(peer_app) } -/// Wait for both pages to drop their linked lines, which the leave and stop -/// end the link. -async fn wait_link_ends(app: &mut PageSession, peer_app: &mut PageSession) -> Result<()> { - let deadline = Instant::now() + LINK_END_BOUND; - loop { - let host_page = app.page_text().await?; - let peer_page = peer_app.page_text().await?; - if !host_page.contains("linked: ") && !peer_page.contains("linked: ") { - return Ok(()); - } - if Instant::now() >= deadline { - bail!("the link did not end after the phones left the hotspot"); - } - sleep(Duration::from_secs(2)).await; - } -} - /// Forget the hotspot's saved profile on both phones, best effort, and wait /// for the joiner to fall back to its usual network. async fn restore_joiner_networks( @@ -1441,72 +1362,6 @@ fn cli_arguments() -> Result { Ok(arguments) } -/// The commit the run built, marked `-dirty` over local changes, and -/// `unknown` where git does not answer. -async fn commit() -> String { - let Ok(root) = repo_path(&[]) else { - return "unknown".to_owned(); - }; - match Command::new("git") - .arg("-C") - .arg(root) - .args(["describe", "--always", "--dirty", "--abbrev=40"]) - .output() - .await - { - Ok(output) if output.status.success() => { - String::from_utf8_lossy(&output.stdout).trim().to_owned() - } - _ => "unknown".to_owned(), - } -} - -/// The run's summary: the commit, the single-phone proof, and each join with -/// its time and, where it failed, why. -fn summary( - commit: &str, - (proof_took, proof): &(Duration, Result<(), String>), - joins: Result<&[JoinReport], &anyhow::Error>, - restore: Result<(), &anyhow::Error>, -) -> String { - use std::fmt::Write as _; - let line = |name: &str, took: Duration, outcome: Result<(), String>| match outcome { - Ok(()) => format!("{name}: passed in {}s\n", took.as_secs()), - Err(err) => format!("{name}: failed in {}s: {err}\n", took.as_secs()), - }; - let mut text = format!("commit: {commit}\n"); - text += &line("single-phone proof", *proof_took, proof.clone()); - match joins { - Ok(reports) => { - for report in reports { - let name = format!("{} join", report.join.name()); - match (report.took, &report.outcome) { - (Some(took), outcome) => { - text += &line( - &name, - took, - outcome.as_ref().map(drop).map_err(|err| format!("{err:#}")), - ); - } - (None, Err(err)) => { - let _ = writeln!(text, "{name}: not run: {err:#}"); - } - (None, Ok(())) => { - let _ = writeln!(text, "{name}: not run"); - } - } - } - } - Err(err) => { - let _ = writeln!(text, "joins: not run, the setup failed: {err:#}"); - } - } - if let Err(err) = restore { - let _ = writeln!(text, "restore: failed: {err:#}"); - } - text -} - /// Comma-separated `device:host` port pairs. fn parse_reverse(spec: &str) -> Result> { spec.split(',') @@ -2046,27 +1901,6 @@ fn parse_bounds(text: &str) -> Option<(i32, i32, i32, i32)> { } } -/// The offer's lines off a page's text, all three present and the port a -/// number, or none while they are not. -fn parse_offer_lines(page: &str) -> Option { - let line = |label: &str| -> Option { - page.lines() - .find_map(|line| line.strip_prefix(label).map(|rest| rest.trim().to_owned())) - }; - let ssid = line("hotspot ssid: ")?; - let passphrase = line("hotspot passphrase: ")?; - let port = line("hotspot port: ")?; - if port == "none" { - return None; - } - Some(HotspotOfferLines { - ssid, - passphrase, - port: port.parse::().ok()?, - payload: line("hotspot payload: "), - }) -} - /// The ssid a `dumpsys wifi` names as the connected one, absent while it /// names `` or no line at all. fn parse_current_ssid(dump: &str) -> Option { @@ -2093,80 +1927,6 @@ fn saved_network_id(listing: &str, ssid: &str) -> Option { #[cfg(test)] mod tests { - #[test] - fn the_summary_names_every_step_and_why_a_join_failed() { - use std::time::Duration; - let reports = [ - super::JoinReport { - join: super::JoinBy::Form, - took: Some(Duration::from_secs(201)), - outcome: Ok(()), - }, - super::JoinReport { - join: super::JoinBy::Beacon, - took: Some(Duration::from_secs(95)), - outcome: Err(anyhow::anyhow!( - "the second phone never saw the first phone's beacon" - )), - }, - super::JoinReport { - join: super::JoinBy::Payload, - took: None, - outcome: Err(anyhow::anyhow!("the beacon join's cleanup failed")), - }, - ]; - assert_eq!( - super::summary( - "c0626b2", - &(Duration::from_secs(180), Ok(())), - Ok(&reports), - Ok(()) - ), - "commit: c0626b2\n\ - single-phone proof: passed in 180s\n\ - form join: passed in 201s\n\ - beacon join: failed in 95s: the second phone never saw the first phone's beacon\n\ - payload join: not run: the beacon join's cleanup failed\n" - ); - let setup = anyhow::anyhow!("adb install exited with 1"); - assert_eq!( - super::summary( - "c0626b2", - &(Duration::from_secs(180), Ok(())), - Err(&setup), - Ok(()) - ), - "commit: c0626b2\n\ - single-phone proof: passed in 180s\n\ - joins: not run, the setup failed: adb install exited with 1\n" - ); - } - - /// Joins that ran keep their lines when the run's own restore fails after - /// them, and the restore's failure gets its own line. - #[test] - fn a_failed_restore_keeps_the_joins_that_ran() { - use std::time::Duration; - let reports = [super::JoinReport { - join: super::JoinBy::Form, - took: Some(Duration::from_secs(201)), - outcome: Ok(()), - }]; - let restore = anyhow::anyhow!("the browser role did not return"); - assert_eq!( - super::summary( - "c0626b2", - &(Duration::from_secs(180), Ok(())), - Ok(&reports), - Err(&restore) - ), - "commit: c0626b2\n\ - single-phone proof: passed in 180s\n\ - form join: passed in 201s\n\ - restore: failed: the browser role did not return\n" - ); - } - /// The demo's set and service as an A35 on Android 15 dumps them. const BEACON_DUMP: &str = " dev.connetto.dioxusdemo Advertising ID : 3 @@ -2218,7 +1978,7 @@ mod tests { use std::path::{Path, PathBuf}; use std::time::Duration; - use super::{Device, parse_current_ssid, parse_offer_lines, saved_network_id}; + use super::{Device, parse_current_ssid, saved_network_id}; /// A stand-in `adb` that runs `body` with `$state` naming a scratch directory. fn fake_adb(dir: &Path, body: &str) -> PathBuf { @@ -2294,26 +2054,6 @@ echo "adb: device offline" >&2; exit 1"#, assert_eq!(calls.matches("shell true").count(), 1, "{calls}"); } - #[test] - fn the_offer_lines_are_read_off_the_page() { - let page = "\npeer: listening on 41234\n\ - hotspot ssid: My Hotspot\n\ - hotspot passphrase: s3cr3t pass\n\ - hotspot port: 41234\n\ - hotspot payload: WIFI:T:WPA;S:My Hotspot;P:s3cr3t pass;X:41234;;\n\ - hotspot: hosting\n"; - let offer = parse_offer_lines(page).expect("all three lines are there"); - assert_eq!(offer.ssid, "My Hotspot"); - assert_eq!(offer.passphrase, "s3cr3t pass"); - assert_eq!(offer.port, 41234); - assert_eq!( - offer.payload.as_deref(), - Some("WIFI:T:WPA;S:My Hotspot;P:s3cr3t pass;X:41234;;") - ); - assert!(parse_offer_lines("hotspot ssid: only\n").is_none()); - assert!(parse_offer_lines("hotspot port: none\n").is_none()); - } - #[test] fn the_connected_ssid_is_read_and_unknown_is_none() { let dump = "mWifiInfo SSID: \"eduroam\", BSSID: aa:bb:cc:dd:ee:ff\n"; diff --git a/crates/connetto-test-harness/src/bin/connetto-ios-proof.rs b/crates/connetto-test-harness/src/bin/connetto-ios-proof.rs index e57d01e1..31dd7e30 100644 --- a/crates/connetto-test-harness/src/bin/connetto-ios-proof.rs +++ b/crates/connetto-test-harness/src/bin/connetto-ios-proof.rs @@ -2,6 +2,13 @@ //! iPhone or iPad and walks R88's proof on it, which is sign in, sync, write //! offline and upload on reconnect, then R51's re-check after time away. //! +//! With `--peer-device UDID --every-join --hotspot-passphrase PASS` a second +//! paired phone joins the first in one run (R76 slice 9a), the first hosting +//! its Personal Hotspot, which the `XCUITest` tapper under +//! `crates/connetto-test-harness/ios/` switches in Settings, and the second +//! joining it by beacon, payload and form in turn, the tapper tapping the +//! system's prompts, one summary beside the evidence. +//! //! It runs on a Mac as the command of `connetto-demo-stack`, which provides the //! stack and the environment naming it. //! @@ -9,6 +16,7 @@ //! cargo build -p connetto-test-harness --bin connetto-ios-proof //! cargo run -p connetto-test-harness --bin connetto-demo-stack -- \ //! target/debug/connetto-ios-proof [--simulator UDID | --device UDID --identity SHA1] [--app PATH] +//! [--peer-device UDID --every-join --hotspot-passphrase PASS] //! ``` //! //! A simulator build links the keychain entitlements into the executable as @@ -38,13 +46,17 @@ use anyhow::{Context as _, Result, anyhow, bail}; use connetto_test_harness::demo::{order_count, wait_for_count}; use connetto_test_harness::inspector::{PageSession, list_pages}; use connetto_test_harness::ios_signing; +use connetto_test_harness::peer_devices::{own_identity, read_hotspot_offer, wait_link_ends}; +use connetto_test_harness::peer_proof::{HotspotOfferLines, JoinReport, commit, summary}; use connetto_test_harness::relay::Relay; use connetto_test_harness::stack::{ TLS_CERT_VAR, TLS_KEY_VAR, demo_has_device_identity, demo_mobile_features, now_millis, repo_path, }; +use serde_json::{Value, json}; +use tokio::io::AsyncWriteExt as _; use tokio::process::{Child, Command}; -use tokio::time::{Instant, sleep}; +use tokio::time::{Instant, sleep, timeout}; const BUNDLE: &str = "dev.connetto.dioxusdemo"; const PROCESS: &str = "connetto-dioxus-desktop-demo"; @@ -55,11 +67,37 @@ const USER: &str = "alice"; const DEFAULT_SIMULATOR: &str = "iPhone 17 Pro"; /// The variable naming the team ID a simulator build's entitlements carry. const TEAM_ID_VAR: &str = "CONNETTO_IOS_TEAM_ID"; +/// The filter a device's demo logs under, the peer link's and the radios' +/// own steps included, which the demo's log file keeps for the evidence. +const DEMO_LOG_FILTER: &str = "info,connetto_client::away=debug,connetto_dioxus::away=debug,connetto_client::bluetooth=debug,connetto_client::hotspot=debug,connetto_peer=debug,connetto_peer_apple=debug"; +/// Where the demo's phone log can sit in its data container, the +/// directory the platform names after the app first. +const DEMO_LOG_SOURCES: [&str; 2] = [ + "Library/Application Support/connetto-dioxus-demo/connetto-demo.log", + "Library/Application Support/connetto-demo.log", +]; +/// The system's alert button labels in the runner's locales, which the +/// tapper taps in place of the user (R76). +const ALERT_LABELS: [&str; 6] = ["Join", "Accedi", "Connetti", "Allow", "Consenti", "OK"]; +/// The iOS development profile the tapper's runner signs with, as +/// `connetto-ios-signing` names it. +const TAPPER_PROFILE: &str = "connetto tapper development"; #[tokio::main] async fn main() -> Result<()> { let stack = Stack::from_env()?; - let (target, app) = cli_arguments().await?; + let parsed = parse_arguments(&std::env::args().skip(1).collect::>())?; + let identity = parsed.identity; + let target = match (parsed.simulator, parsed.device) { + (simulator, None) => Target::simulator(simulator).await?, + (None, Some(udid)) => Target::Device { + udid, + identity: identity + .clone() + .context("--device needs the --identity connetto-ios-signing printed")?, + }, + _ => unreachable!("parse_arguments checked the simulator and device"), + }; let evidence = repo_path(&["target", "ios-proof"])?.join(format!("{}-{}", target.udid(), now_millis())); tokio::fs::create_dir_all(&evidence) @@ -67,14 +105,81 @@ async fn main() -> Result<()> { .with_context(|| format!("creating {}", evidence.display()))?; eprintln!("evidence in {}", evidence.display()); + let app = match parsed.app { + Some(app) => app, + None => target.build(&evidence).await?, + }; + let (mut relay, sync_url) = relay_for(&target, &stack).await?; let started = std::time::SystemTime::now(); - let outcome = prove(&target, app, &stack, &evidence).await; + let outcome = if parsed.every_join { + let started = Instant::now(); + let outcome = prove(&target, &app, &stack, &evidence, &mut relay, &sync_url).await; + let proved = ( + started.elapsed(), + outcome.as_ref().map_err(|err| format!("{err:#}")).map(drop), + ); + // The joins run only on a proved phone, and a failed proof keeps its + // own error. + match &outcome { + Ok(()) => { + let identity = identity.context("a device run names no --identity")?; + let joiner = Target::Device { + udid: parsed + .peer + .context("a --every-join run names no --peer-device")?, + identity: identity.clone(), + }; + let passphrase = parsed + .passphrase + .context("a --every-join run names no passphrase")?; + // A tapper that does not build fails the joins' setup, which + // the summary records like any other. + let tappers = async { + let host = Tapper::build(target.udid(), &identity, &evidence).await?; + let joiner = Tapper::build(joiner.udid(), &identity, &evidence).await?; + Ok::<_, anyhow::Error>((host, joiner)) + } + .await; + match tappers { + Ok((host_tapper, joiner_tapper)) => { + joins_with_summary( + &target, + &joiner, + &app, + &host_tapper, + &joiner_tapper, + &stack, + &evidence, + &passphrase, + &sync_url, + proved, + ) + .await + } + Err(err) => { + let summary = summary(&commit().await, &proved, Err(&err), Ok(())); + eprintln!("{summary}"); + tokio::fs::write(evidence.join("summary.txt"), &summary) + .await + .context("writing the summary")?; + Err(err) + } + } + } + Err(_) => outcome, + } + } else { + prove(&target, &app, &stack, &evidence, &mut relay, &sync_url).await + }; if let Target::Simulator { .. } = &target { let log = target.app_log(started).await.unwrap_or_default(); tokio::fs::write(evidence.join("app-log.txt"), log) .await .context("writing the app log")?; } + if let Err(err) = target.copy_demo_log(&evidence).await { + eprintln!("the demo's log stayed on {}: {err:#}", target.udid()); + } if let Err(err) = &outcome { eprintln!("proof failed: {err:#}"); } @@ -85,9 +190,6 @@ async fn main() -> Result<()> { /// What `connetto-demo-stack` tells its command. struct Stack { server: String, - /// The certificate and key a TLS relay in front of the sync server - /// serves, which the stack names when it is on the network. - tls: Option<(PathBuf, PathBuf)>, auth_origin: String, pg_url: String, issuer: String, @@ -101,16 +203,18 @@ impl Stack { }; Ok(Self { server: var("CONNETTO_DEMO_SERVER")?, - tls: std::env::var(TLS_CERT_VAR) - .ok() - .zip(std::env::var(TLS_KEY_VAR).ok()) - .map(|(cert, key)| (PathBuf::from(cert), PathBuf::from(key))), auth_origin: var("CONNETTO_DEMO_AUTH_ORIGIN")?, pg_url: var("CONNETTO_DEMO_PG")?, issuer: var("CONNETTO_DEMO_ISSUER")?, }) } + /// Whether the stack serves its routes in TLS, which it does on the + /// network. + fn on_tls(&self) -> bool { + self.auth_origin.starts_with("https://") + } + /// The host clients reach the sync server on. fn host(&self) -> Result<&str> { self.server @@ -120,53 +224,51 @@ impl Stack { } } +/// A relay in front of the sync server, and the URL the demo dials it at. +/// +/// A stack on the network terminates TLS on its own listener, so the relay +/// hands the bytes through and the demo dials it in `wss` by the stack's host, +/// which the certificate names. A stack on the loopback speaks plain HTTP, +/// which only a simulator may dial, in `ws` on the Mac's loopback. +async fn relay_for(target: &Target, stack: &Stack) -> Result<(Relay, String)> { + if stack.on_tls() { + let relay = Relay::start("0.0.0.0:0", &stack.server).await?; + let url = format!( + "wss://{}:{}{}", + stack.host()?, + relay.address().port(), + connetto_server::SYNC_PATH + ); + return Ok((relay, url)); + } + if matches!(target, Target::Device { .. }) { + bail!("a device run needs the stack on TLS, under {TLS_CERT_VAR} and {TLS_KEY_VAR}"); + } + let relay = Relay::start("127.0.0.1:0", &stack.server).await?; + let url = format!( + "ws://127.0.0.1:{}{}", + relay.address().port(), + connetto_server::SYNC_PATH + ); + Ok((relay, url)) +} + async fn prove( target: &Target, - app: Option, + app: &Path, stack: &Stack, evidence: &Path, + relay: &mut Relay, + sync_url: &str, ) -> Result<()> { - let app = match app { - Some(app) => app, - None => target.build(evidence).await?, - }; - // A simulator dials the relay on the Mac's loopback in plain `ws`. A - // device dials it across the network, where the client accepts only - // `wss`, so the relay terminates TLS with the stack's certificate. - let (mut relay, sync_url) = match target { - Target::Simulator { .. } => { - let relay = Relay::start("127.0.0.1:0", &stack.server).await?; - let url = format!( - "ws://127.0.0.1:{}{}", - relay.address().port(), - connetto_server::SYNC_PATH - ); - (relay, url) - } - Target::Device { .. } => { - let (cert, key) = stack - .tls - .as_ref() - .context("a device run needs the stack's TLS certificate")?; - let relay = Relay::start_tls("0.0.0.0:0", &stack.server, cert, key).await?; - let url = format!( - "wss://{}:{}{}", - stack.host()?, - relay.address().port(), - connetto_server::SYNC_PATH - ); - (relay, url) - } - }; - step("install"); - target.install(&app).await?; + target.install(app).await?; let mut inspector = Inspector::start(target, evidence).await?; step("sign in"); target .launch(&[ - ("CONNETTO_DEMO_WS", &sync_url), + ("CONNETTO_DEMO_WS", sync_url), ("CONNETTO_DEMO_AUTH_ORIGIN", &stack.auth_origin), ("CONNETTO_DEMO_PG", &stack.pg_url), ]) @@ -268,12 +370,21 @@ async fn sign_in( .try_page(|url| url.starts_with("dioxus://")) .await? { - let text = app.page_text().await?; - if text.contains("status: connected") { - eprintln!("an earlier credential signed the demo in, signing out first"); - sign_out(&mut app).await?; + // A page that does not answer is looked for again through a fresh + // proxy, since a relaunched app's inspector can stall once. + match app.page_text().await { + Ok(text) => { + if text.contains("status: connected") { + eprintln!("an earlier credential signed the demo in, signing out first"); + sign_out(&mut app).await?; + } + demo = text.split_whitespace().collect::>().join(" "); + } + Err(err) => { + demo = format!("a page that did not answer ({err:#})"); + inspector.restart_at = Instant::now(); + } } - demo = text.split_whitespace().collect::>().join(" "); } if Instant::now() >= deadline { let shown: String = demo.chars().take(400).collect(); @@ -298,6 +409,18 @@ async fn sign_out(app: &mut PageSession) -> Result<()> { .await } +/// Sign the demo out on `phone` when it stands signed in, so a run leaves no +/// account in the keychain's account index, which outlives the demo's +/// uninstall while each run's provider names a new account. +async fn leave_signed_out(phone: &Target, evidence: &Path) -> Result<()> { + let mut inspector = Inspector::start(phone, evidence).await?; + let mut app = inspector.page(|url| url.starts_with("dioxus://")).await?; + if app.page_text().await?.contains("Signed in as:") { + sign_out(&mut app).await?; + } + Ok(()) +} + /// Fill the dev user into the identity provider's form and submit it. The /// authentication session catches the final redirect itself, so no trusted /// input is needed. @@ -356,31 +479,81 @@ fn step(name: &str) { eprintln!("== {name}"); } -/// `--simulator`, or `--device` with `--identity`, and `--app`. -async fn cli_arguments() -> Result<(Target, Option)> { - let mut simulator = None; - let mut device = None; - let mut identity = None; - let mut app = None; - let mut args = std::env::args().skip(1); +/// What the command line names, before anything runs. +#[derive(Default)] +struct ParsedArguments { + simulator: Option, + device: Option, + identity: Option, + app: Option, + peer: Option, + every_join: bool, + passphrase: Option, +} + +/// `--simulator`, or `--device` with `--identity`, `--app`, and, for the +/// two-phone run, `--peer-device`, `--every-join` and `--hotspot-passphrase`. +fn parse_arguments(args: &[String]) -> Result { + let mut arguments = ParsedArguments::default(); + let mut args = args.iter(); while let Some(arg) = args.next() { match arg.as_str() { - "--simulator" => simulator = Some(args.next().context("--simulator takes a UDID")?), - "--device" => device = Some(args.next().context("--device takes a UDID")?), - "--identity" => identity = Some(args.next().context("--identity takes a SHA-1")?), - "--app" => app = Some(PathBuf::from(args.next().context("--app takes a path")?)), + "--simulator" => { + arguments.simulator = Some(argument_value(&mut args, arg)?.to_owned()); + } + "--device" => arguments.device = Some(argument_value(&mut args, arg)?.to_owned()), + "--identity" => { + arguments.identity = Some(argument_value(&mut args, arg)?.to_owned()); + } + "--app" => { + arguments.app = Some(PathBuf::from(argument_value(&mut args, arg)?.to_owned())); + } + "--peer-device" => { + arguments.peer = Some(argument_value(&mut args, arg)?.to_owned()); + } + "--hotspot-passphrase" => { + arguments.passphrase = Some(argument_value(&mut args, arg)?.to_owned()); + } + "--every-join" => arguments.every_join = true, other => bail!("unknown argument {other:?}"), } } - let target = match (simulator, device, identity) { - (simulator, None, None) => Target::simulator(simulator).await?, - (None, Some(udid), Some(identity)) => Target::Device { udid, identity }, - (None, Some(_), None) => { - bail!("--device needs the --identity connetto-ios-signing printed") - } - _ => bail!("give either --simulator or --device, not both"), - }; - Ok((target, app)) + if arguments.simulator.is_some() && arguments.device.is_some() { + bail!("give either --simulator or --device, not both"); + } + if arguments.device.is_some() && arguments.identity.is_none() { + bail!("--device needs the --identity connetto-ios-signing printed"); + } + if arguments.peer.is_some() && !arguments.every_join { + bail!("--peer-device needs --every-join"); + } + if arguments.every_join + && (arguments.device.is_none() + || arguments.identity.is_none() + || arguments.peer.is_none() + || arguments.passphrase.is_none()) + { + bail!("--every-join needs --device, --identity, --peer-device and --hotspot-passphrase"); + } + if arguments.every_join && arguments.simulator.is_some() { + bail!("--every-join runs on a paired phone, so it needs --device, not --simulator"); + } + if arguments + .device + .as_deref() + .zip(arguments.peer.as_deref()) + .is_some_and(|(device, peer)| device == peer) + { + bail!("--peer-device names the same device as --device"); + } + Ok(arguments) +} + +/// The value the argument names, which the argument is not complete without. +fn argument_value<'a>(args: &mut std::slice::Iter<'a, String>, arg: &str) -> Result<&'a str> { + args.next() + .map(String::as_str) + .ok_or_else(|| anyhow!("{arg} needs a value")) } /// Where the proof runs. @@ -421,10 +594,7 @@ impl Target { match self { Self::Simulator { .. } => { let entitlements = evidence.join("simulator-entitlements.plist"); - let team = match std::env::var(TEAM_ID_VAR) { - Ok(team) => team, - Err(_) => team_prefix().await?, - }; + let team = signing_team().await?; tokio::fs::write(&entitlements, simulator_entitlements(&team)) .await .context("writing the simulator entitlements")?; @@ -517,6 +687,7 @@ impl Target { Self::Device { udid, .. } => { let env = serde_json::Value::Object( env.iter() + .chain(&[("RUST_LOG", DEMO_LOG_FILTER)]) .map(|(key, value)| ((*key).to_owned(), (*value).into())) .collect(), ) @@ -593,6 +764,44 @@ impl Target { .with_context(|| format!("writing the {name} page text")) } + /// Copy the demo's own log off a device into the evidence as + /// `demo-.log`, since a device keeps no app's stdout. A simulator + /// answers through [`Self::app_log`] instead. + async fn copy_demo_log(&self, evidence: &Path) -> Result<()> { + let Self::Device { udid, .. } = self else { + return Ok(()); + }; + let destination = evidence.join(format!("demo-{udid}.log")); + let destination = destination + .to_str() + .context("an evidence path that is not UTF-8")?; + let mut last = None; + for source in DEMO_LOG_SOURCES { + match xcrun(&[ + "devicectl", + "device", + "copy", + "from", + "--device", + udid, + "--domain-type", + "appDataContainer", + "--domain-identifier", + BUNDLE, + "--source", + source, + "--destination", + destination, + ]) + .await + { + Ok(_) => return Ok(()), + Err(err) => last = Some(err), + } + } + Err(last.unwrap_or_else(|| anyhow!("no place to look for the demo's log"))) + } + /// The demo's log lines since `since`, on a simulator. async fn app_log(&self, since: std::time::SystemTime) -> Result { let seconds = since.elapsed().unwrap_or_default().as_secs() + 5; @@ -612,6 +821,702 @@ impl Target { .await } } +/// How the second phone learns the hotspot the first one hosts. +#[derive(Clone, Copy, PartialEq, Eq)] +enum JoinBy { + /// The first phone's Bluetooth beacon and the exchange behind it (R76). + Beacon, + /// The first phone's `WIFI:` payload pasted into the second's join form + /// (R76 decision 24). + Payload, + /// The first phone's offer lines typed into the second's join form. + Form, +} + +impl JoinBy { + /// Every join, in the order `--every-join` runs them (R76 slice 9a). + const EVERY: [Self; 3] = [Self::Beacon, Self::Payload, Self::Form]; + + /// The join's name in the summary and the evidence. + const fn name(self) -> &'static str { + match self { + Self::Beacon => "beacon", + Self::Payload => "payload", + Self::Form => "form", + } + } +} + +/// Make the joins, write the summary, and fail when any join failed. +#[expect( + clippy::too_many_arguments, + reason = "the two phones, their tappers, the build, the stack, the evidence, the passphrase, the sync url and the proof are each the run's own" +)] +async fn joins_with_summary( + host: &Target, + joiner: &Target, + app: &Path, + host_tapper: &Tapper, + joiner_tapper: &Tapper, + stack: &Stack, + evidence: &Path, + passphrase: &str, + sync_url: &str, + proved: (Duration, Result<(), String>), +) -> Result<()> { + let reports = peer_proof( + host, + joiner, + app, + host_tapper, + joiner_tapper, + stack, + evidence, + passphrase, + sync_url, + ) + .await; + for phone in [host, joiner] { + if let Err(err) = leave_signed_out(phone, evidence).await { + eprintln!("{} stayed signed in: {err:#}", phone.udid()); + } + } + if let Err(err) = joiner.copy_demo_log(evidence).await { + eprintln!("the demo's log stayed on {}: {err:#}", joiner.udid()); + } + let summary = summary(&commit().await, &proved, reports.as_deref(), Ok(())); + eprintln!("{summary}"); + tokio::fs::write(evidence.join("summary.txt"), &summary) + .await + .context("writing the summary")?; + let failed: Vec<&str> = reports? + .iter() + .filter(|report| report.outcome.is_err()) + .map(|report| report.name) + .collect(); + if failed.is_empty() { + Ok(()) + } else { + Err(anyhow!("the {} join failed", failed.join(", "))) + } +} + +/// The two-phone peer proof (R76 slice 9a): `host` hosts its Personal +/// Hotspot, switched on by the tapper, and `joiner` signs in as its own +/// account and joins it, the tapper tapping the system's prompts in place of +/// the user, until both pages name the other as linked. It ends with both +/// phones back at rest and the Personal Hotspot off. +#[expect( + clippy::too_many_arguments, + reason = "the two phones, their tappers, the build, the stack, the evidence, the passphrase and the sync url are each the walk's own" +)] +async fn peer_proof( + host: &Target, + joiner: &Target, + app: &Path, + host_tapper: &Tapper, + joiner_tapper: &Tapper, + stack: &Stack, + evidence: &Path, + passphrase: &str, + sync_url: &str, +) -> Result> { + if !demo_has_device_identity() { + bail!("the peer proof needs a demo build with its device identity"); + } + step("install the demo on the second phone"); + joiner.install(app).await?; + let mut joiner_inspector = Inspector::start(joiner, evidence).await?; + joiner + .launch(&[ + ("CONNETTO_DEMO_WS", sync_url), + ("CONNETTO_DEMO_AUTH_ORIGIN", &stack.auth_origin), + ("CONNETTO_DEMO_PG", &stack.pg_url), + ]) + .await?; + let login_prefix = format!("{}/authorize?", stack.issuer); + step("sign in the second phone"); + let mut peer_app = sign_in(joiner, &mut joiner_inspector, &login_prefix, evidence).await?; + // A local-network prompt can show while the page first browses. + joiner_tapper + .run("tap-alert", 40, evidence, "peer-local-network") + .await?; + peer_app + .wait_for_text("device: certified until", Duration::from_secs(60)) + .await?; + joiner + .record(&mut peer_app, evidence, "peer-signed-in") + .await?; + turn_on_bluetooth(&mut peer_app, joiner_tapper, evidence).await?; + + // The host signed out at the end of its proof, so it signs back in. + step("sign the host back in"); + host.launch(&[ + ("CONNETTO_DEMO_WS", sync_url), + ("CONNETTO_DEMO_AUTH_ORIGIN", &stack.auth_origin), + ("CONNETTO_DEMO_PG", &stack.pg_url), + ]) + .await?; + let mut host_inspector = Inspector::start(host, evidence).await?; + let mut host_app = sign_in(host, &mut host_inspector, &login_prefix, evidence).await?; + host_app + .wait_for_text("device: certified until", Duration::from_secs(60)) + .await?; + host.record(&mut host_app, evidence, "host-signed-in") + .await?; + + let host_identity = own_identity(&mut host_app).await?; + let joiner_identity = own_identity(&mut peer_app).await?; + // The Personal Hotspot names its network after the device. + let host_name = device_name(host.udid()).await?; + + let mut reports = Vec::with_capacity(JoinBy::EVERY.len()); + for &join in &JoinBy::EVERY { + let started = Instant::now(); + // Each join takes a fresh page, since the last moved the joiner's network. + let outcome = match joiner_inspector.reattach().await { + Ok(page) => { + peer_app = page; + one_join( + host, + joiner, + &mut host_app, + &mut host_inspector, + &mut peer_app, + &mut joiner_inspector, + host_tapper, + joiner_tapper, + evidence, + (&host_identity, &joiner_identity), + &host_name, + passphrase, + join, + ) + .await + } + Err(err) => Err(err), + }; + if let Err(err) = &outcome { + eprintln!("the {} join failed: {err:#}", join.name()); + let _ = host + .record( + &mut host_app, + evidence, + &format!("{}-failure-host", join.name()), + ) + .await; + let _ = joiner + .record( + &mut peer_app, + evidence, + &format!("{}-failure-peer", join.name()), + ) + .await; + } + reports.push(JoinReport { + name: join.name(), + took: Some(started.elapsed()), + outcome, + }); + } + Ok(reports) +} + +/// Turn Bluetooth on through the demo, since a fresh install has not been +/// allowed it and the joiner scans only once it is, so the user's own action +/// asks for it (R76 decision 21). +async fn turn_on_bluetooth(app: &mut PageSession, tapper: &Tapper, evidence: &Path) -> Result<()> { + step("turn on Bluetooth on the second phone"); + app.click("Turn on Bluetooth").await?; + tapper + .run("tap-alert", 40, evidence, "peer-bluetooth") + .await?; + app.wait_for_text("bluetooth: ready", Duration::from_secs(30)) + .await +} + +/// One join, from a Personal Hotspot hosted on the first phone to the link's +/// end, and the phones back at rest whatever happened. +#[expect( + clippy::too_many_arguments, + reason = "the two phones, their pages, inspectors and tappers, the evidence, both identities, the hotspot name and password and the join are each the step's own" +)] +async fn one_join( + host: &Target, + joiner: &Target, + host_app: &mut PageSession, + host_inspector: &mut Inspector, + peer_app: &mut PageSession, + joiner_inspector: &mut Inspector, + host_tapper: &Tapper, + joiner_tapper: &Tapper, + evidence: &Path, + (host_identity, joiner_identity): (&str, &str), + host_name: &str, + passphrase: &str, + join: JoinBy, +) -> Result<()> { + let shot = |name: &str| format!("{}-{name}", join.name()); + let walked = async { + step(&format!( + "turn Personal Hotspot on on the first phone for the {} join", + join.name() + )); + switch_and_reattach( + true, + host_tapper, + host_inspector, + host_app, + evidence, + &shot("hotspot-on"), + ) + .await?; + step(&format!( + "host the Personal Hotspot on the first phone for the {} join", + join.name() + )); + host_app.set_input("personal-ssid", host_name).await?; + host_app + .set_input("personal-passphrase", passphrase) + .await?; + host_app.click("Host my Personal Hotspot").await?; + // The call prompts at once when Bluetooth is off, and blocks on it. + let _ = host_tapper + .run("tap-alert", 40, evidence, &shot("host-alert")) + .await; + let offer = read_hotspot_offer(host_app).await?; + host.record(host_app, evidence, &shot("host-offer")).await?; + + join_on_second_phone(join, &offer, joiner, peer_app, joiner_tapper, evidence).await?; + // The joiner moved onto the hotspot, which can reset the links both + // pages' sessions ride on. + *peer_app = joiner_inspector.reattach().await?; + *host_app = host_inspector.reattach().await?; + + step("link through the hotspot"); + wait_linked(host_app, peer_app, host_identity, joiner_identity).await?; + host.record(host_app, evidence, &shot("host-linked")) + .await?; + joiner + .record(peer_app, evidence, &shot("peer-linked")) + .await?; + + step("leave the hotspot on the second phone"); + peer_app.click("Leave the hotspot").await?; + step("stop the hotspot on the first phone"); + host_app.click("Stop the hotspot").await?; + wait_link_ends(host_app, peer_app).await?; + host.record(host_app, evidence, &shot("host-unlinked")) + .await + } + .await; + // Back to rest whatever happened, so the next join starts clean: the + // pages' own leave and stop, then the Personal Hotspot off, the first + // failure kept. + if walked.is_err() { + let _ = peer_app.click("Leave the hotspot").await; + let _ = host_app.click("Stop the hotspot").await; + } + let off = switch_and_reattach( + false, + host_tapper, + host_inspector, + host_app, + evidence, + &shot("hotspot-off"), + ) + .await; + let _ = joiner.record(peer_app, evidence, &shot("peer-clean")).await; + walked.and(off) +} + +/// The second phone's side of one join, up to the system's join prompt +/// tapped. +async fn join_on_second_phone( + join: JoinBy, + offer: &HotspotOfferLines, + joiner: &Target, + peer_app: &mut PageSession, + joiner_tapper: &Tapper, + evidence: &Path, +) -> Result<()> { + match join { + JoinBy::Beacon => { + step("find the beacon on the second phone"); + wait_for_single_nearby(peer_app).await?; + joiner + .record(peer_app, evidence, &format!("{}-peer-nearby", join.name())) + .await?; + peer_app.click("Join the nearby host").await?; + // The line shows while the exchange and then the join run, + // behind which the system's prompts stand. + peer_app + .wait_for_text("joining the nearby host", Duration::from_secs(10)) + .await?; + joiner_tapper + .run( + "tap-alert", + 40, + evidence, + &format!("{}-joiner-alert", join.name()), + ) + .await?; + } + JoinBy::Payload => { + let payload = offer + .payload + .as_deref() + .context("the host shows no WIFI: payload")?; + step("paste the payload on the second phone"); + peer_app.set_input("payload", payload).await?; + peer_app.click("Join from the payload").await?; + peer_app + .wait_for_text("joining the hotspot", Duration::from_secs(10)) + .await + .context("the second phone did not take the payload")?; + joiner_tapper + .run( + "tap-alert", + 40, + evidence, + &format!("{}-joiner-alert", join.name()), + ) + .await?; + } + JoinBy::Form => { + step("fill the join form on the second phone"); + peer_app.set_input("ssid", &offer.ssid).await?; + peer_app.set_input("passphrase", &offer.passphrase).await?; + peer_app.set_input("port", &offer.port.to_string()).await?; + peer_app.click("Join the hotspot").await?; + peer_app + .wait_for_text("joining the hotspot", Duration::from_secs(10)) + .await?; + joiner_tapper + .run( + "tap-alert", + 40, + evidence, + &format!("{}-joiner-alert", join.name()), + ) + .await?; + } + } + Ok(()) +} + +/// Wait until each phone names the other as linked. +async fn wait_linked( + host_app: &mut PageSession, + peer_app: &mut PageSession, + host_identity: &str, + joiner_identity: &str, +) -> Result<()> { + if host_app + .wait_for_text( + &format!("linked: {joiner_identity}"), + Duration::from_secs(90), + ) + .await + .is_err() + { + let host_page = host_app.page_text().await.unwrap_or_default(); + let peer_page = peer_app.page_text().await.unwrap_or_default(); + bail!( + "the first phone never named the second as linked, the first phone shows:\n{host_page}\nthe second phone shows:\n{peer_page}" + ); + } + peer_app + .wait_for_text(&format!("linked: {host_identity}"), Duration::from_secs(30)) + .await?; + Ok(()) +} + +/// Switch the host's Personal Hotspot and take a fresh session on its page, +/// since the switch resets the USB link the old session rode on. +async fn switch_and_reattach( + on: bool, + tapper: &Tapper, + inspector: &mut Inspector, + app: &mut PageSession, + evidence: &Path, + name: &str, +) -> Result<()> { + let switched = tapper.switch_hotspot(on, evidence, name).await; + *app = inspector.reattach().await?; + switched +} + +/// Wait until the joiner lists exactly one nearby host, whose beacon the join +/// button takes as the strongest. +async fn wait_for_single_nearby(app: &mut PageSession) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let page = app.page_text().await?; + let nearby: Vec<&str> = page + .lines() + .filter(|line| line.starts_with("nearby: ")) + .collect(); + if nearby.len() == 1 { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("the second phone never saw exactly one nearby host, it lists: {nearby:?}"); + } + sleep(Duration::from_secs(1)).await; + } +} + +/// The name of a paired device, which its Personal Hotspot uses as the +/// network name. +async fn device_name(udid: &str) -> Result { + let listing = std::env::temp_dir().join("connetto-ios-devices.json"); + xcrun(&[ + "devicectl", + "list", + "devices", + "--json-output", + &listing.display().to_string(), + ]) + .await?; + let listed: Value = serde_json::from_slice( + &tokio::fs::read(&listing) + .await + .with_context(|| format!("reading {}", listing.display()))?, + ) + .context("parsing the device list")?; + for device in listed["result"]["devices"].as_array().into_iter().flatten() { + if device["hardwareProperties"]["udid"] == udid { + return device["deviceProperties"]["name"] + .as_str() + .map(str::to_owned) + .context("the device list names no device"); + } + } + bail!("no paired device with the udid {udid}") +} + +/// The `XCUITest` tapper on one phone, which taps the system's alerts and +/// switches Personal Hotspot where the demo and its page cannot. +struct Tapper { + udid: String, + xctestrun: PathBuf, +} + +impl Tapper { + /// Build the tapper bundle for the device, signed with the development + /// profile, into the evidence. + async fn build(udid: &str, identity: &str, evidence: &Path) -> Result { + ios_signing::unlock().await?; + let project = + repo_path(&["crates", "connetto-test-harness", "ios"])?.join("Tapper.xcodeproj"); + let derived = evidence.join(format!("tapper-{udid}")); + step(&format!("build the tapper for {udid}")); + let team = signing_team().await?; + let output = Command::new("xcodebuild") + .arg("build-for-testing") + .arg("-project") + .arg(&project) + .arg("-scheme") + .arg("Tapper") + .arg("-configuration") + .arg("Debug") + .arg("-destination") + .arg(format!("id={udid}")) + .arg("-derivedDataPath") + .arg(&derived) + .arg(format!("CODE_SIGN_IDENTITY={identity}")) + .arg(format!("DEVELOPMENT_TEAM={team}")) + .arg(format!("PROVISIONING_PROFILE_SPECIFIER={TAPPER_PROFILE}")) + .output() + .await + .context("starting xcodebuild build-for-testing")?; + if !output.status.success() { + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + let lines: Vec<&str> = text.lines().collect(); + let tail = lines[lines.len().saturating_sub(40)..].join("\n"); + bail!("xcodebuild build-for-testing failed:\n{tail}"); + } + let products = derived.join("Build/Products"); + let mut read = tokio::fs::read_dir(&products) + .await + .with_context(|| format!("reading {}", products.display()))?; + let mut found = Vec::new(); + while let Some(entry) = read.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "xctestrun") { + found.push(path); + } + } + let xctestrun = match found.as_slice() { + [run] => run.clone(), + _ => bail!( + "the build left no single xctestrun in {}", + products.display() + ), + }; + Ok(Self { + udid: udid.to_owned(), + xctestrun, + }) + } + + /// Switch Personal Hotspot on or off. Switching it can reset the USB + /// link the Mac reaches the phone over, which ends the run before it + /// reports, so a failed run is followed by one more, which reads the + /// switch and taps only when the first tap did not land. + async fn switch_hotspot(&self, on: bool, evidence: &Path, name: &str) -> Result<()> { + let action = if on { "hotspot-on" } else { "hotspot-off" }; + if let Err(err) = self.run(action, 120, evidence, name).await { + eprintln!("the tapper's {action} ended without a report ({err}), checking the switch"); + self.run(action, 120, evidence, &format!("{name}-check")) + .await?; + } + Ok(()) + } + + /// Run one action on the device through a run file carrying its + /// arguments, appending xcodebuild's output to the evidence. The run file + /// sits beside the built one, since xcodebuild finds the products from + /// the run file's own folder. + async fn run(&self, action: &str, bound: u64, evidence: &Path, name: &str) -> Result<()> { + let run = self + .xctestrun + .with_file_name(format!("tapper-{name}.xctestrun")); + let base = plist_json(&self.xctestrun).await?; + let edited = inject_environment(&base, &tapper_environment(action, &ALERT_LABELS, bound)); + write_plist_json(&run, &edited).await?; + step(&format!("the tapper runs {action} on {}", self.udid)); + let log = evidence.join(format!("tapper-{name}.log")); + let child = Command::new("xcodebuild") + .arg("test-without-building") + .arg("-xctestrun") + .arg(&run) + .arg("-destination") + .arg(format!("id={}", self.udid)) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .context("starting xcodebuild test-without-building")?; + let output = timeout( + Duration::from_secs(bound.saturating_add(600)), + child.wait_with_output(), + ) + .await + .map_err(|_| anyhow!("the tapper's {action} action outlived its bound"))? + .context("waiting for the tapper run")?; + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + let mut file = tokio::fs::OpenOptions::new() + .append(true) + .create(true) + .open(&log) + .await + .with_context(|| format!("writing {}", log.display()))?; + file.write_all(text.as_bytes()) + .await + .with_context(|| format!("writing {}", log.display()))?; + if !output.status.success() { + bail!( + "the tapper's {action} action failed, its log in {}", + log.display() + ); + } + Ok(()) + } +} + +/// The `TEST_RUNNER_` environment one tapper action carries. +fn tapper_environment(action: &str, labels: &[&str], bound: u64) -> Value { + json!({ + "TEST_RUNNER_ACTION": action, + "TEST_RUNNER_LABELS": labels.join(","), + "TEST_RUNNER_TIMEOUT": bound.to_string(), + }) +} + +/// The run file with the action's environment written into each test +/// target's `EnvironmentVariables`, where xcodebuild hands it to the test +/// process on the device. +fn inject_environment(run: &Value, environment: &Value) -> Value { + let mut run = run.clone(); + let Some(Value::Array(configurations)) = run.get_mut("TestConfigurations") else { + return run; + }; + for configuration in configurations.iter_mut().filter_map(Value::as_object_mut) { + let Some(Value::Array(targets)) = configuration.get_mut("TestTargets") else { + continue; + }; + for target in targets.iter_mut().filter_map(Value::as_object_mut) { + let variables = target + .entry("EnvironmentVariables") + .or_insert_with(|| Value::Object(serde_json::Map::new())); + if let (Value::Object(variables), Value::Object(environment)) = (variables, environment) + { + for (key, value) in environment { + variables.insert(key.clone(), value.clone()); + } + } + } + } + run +} + +/// Read a plist, in whatever flavor xcodebuild wrote it, as a JSON value. +async fn plist_json(path: &Path) -> Result { + let json = plist_json_path(path); + let status = Command::new("plutil") + .arg("-convert") + .arg("json") + .arg("-o") + .arg(&json) + .arg(path) + .status() + .await + .context("starting plutil")?; + if !status.success() { + bail!("plutil could not read {}", path.display()); + } + serde_json::from_slice( + &tokio::fs::read(&json) + .await + .with_context(|| format!("reading {}", json.display()))?, + ) + .with_context(|| format!("parsing {}", path.display())) +} + +/// Write a JSON value as the XML plist xcodebuild reads back. +async fn write_plist_json(path: &Path, value: &Value) -> Result<()> { + let json = plist_json_path(path); + tokio::fs::write(&json, value.to_string()) + .await + .with_context(|| format!("writing {}", json.display()))?; + let status = Command::new("plutil") + .arg("-convert") + .arg("xml1") + .arg("-o") + .arg(path) + .arg(&json) + .status() + .await + .context("starting plutil")?; + tokio::fs::remove_file(&json).await?; + if !status.success() { + bail!("plutil could not write {}", path.display()); + } + Ok(()) +} + +/// The JSON file beside a plist the `plutil` round trip goes through. +fn plist_json_path(path: &Path) -> PathBuf { + path.with_file_name(format!( + "{}.json", + path.file_stem() + .map_or("plist", |stem| stem.to_str().unwrap_or("plist")) + )) +} /// Sign every framework `dx` bundled, then the app again with its own /// entitlements, in the order Xcode signs, and verify the result so an @@ -685,6 +1590,15 @@ async fn xcrun(args: &[&str]) -> Result { /// The organizational unit of the Mac's Apple Development certificate, which /// is the team prefix of the app's identifier. +/// The team the run signs with, `CONNETTO_IOS_TEAM_ID` where it names one, +/// and else the team of the keychain's Apple Development certificate. +async fn signing_team() -> Result { + match std::env::var(TEAM_ID_VAR) { + Ok(team) => Ok(team), + Err(_) => team_prefix().await, + } +} + async fn team_prefix() -> Result { let pem = Command::new("security") .args(["find-certificate", "-c", "Apple Development", "-p"]) @@ -831,6 +1745,15 @@ impl Inspector { } } + /// A fresh session on the demo's page through a restarted proxy, for + /// after a Personal Hotspot switch, which resets the USB link the proxy + /// and every page session ride on. + async fn reattach(&mut self) -> Result { + self.restart_at = Instant::now(); + self.keep_alive().await?; + self.page(|url| url.starts_with("dioxus://")).await + } + /// Restart the proxy after [`PROXY_RESTART`] without a match. async fn keep_alive(&mut self) -> Result<()> { if Instant::now() < self.restart_at { @@ -978,4 +1901,117 @@ mod tests { Some("7W8527FJJE") ); } + + /// `--every-join` walks the joins in the spec's order. + #[test] + fn the_every_join_walks_beacon_payload_form() { + assert_eq!( + super::JoinBy::EVERY.map(super::JoinBy::name), + ["beacon", "payload", "form"] + ); + } + + /// The argument checks name the missing or clashing flag. + #[test] + fn the_peer_arguments_are_checked() { + let args = |parts: &[&str]| parts.iter().copied().map(String::from).collect::>(); + assert!(super::parse_arguments(&args(&[])).is_ok()); + assert!(super::parse_arguments(&args(&["--simulator", "SIM-1"])).is_ok()); + assert!( + super::parse_arguments(&args(&[ + "--device", + "u1", + "--identity", + "sha1", + "--peer-device", + "u2" + ])) + .is_err(), + "--peer-device without --every-join" + ); + let every = [ + "--device", + "u1", + "--identity", + "sha1", + "--peer-device", + "u2", + "--every-join", + ]; + assert!( + super::parse_arguments(&every.iter().copied().map(String::from).collect::>()) + .is_err(), + "--every-join without the passphrase" + ); + let complete = [&every[..], &["--hotspot-passphrase", "pass"]].concat(); + assert!( + super::parse_arguments( + &complete + .iter() + .copied() + .map(String::from) + .collect::>() + ) + .is_ok(), + "a complete two-phone run" + ); + for broken in [ + args(&["--simulator", "SIM-1", "--device", "u1"]), + args(&["--device", "u1"]), + args(&[ + "--device", + "u1", + "--identity", + "sha1", + "--peer-device", + "u1", + "--every-join", + "--hotspot-passphrase", + "pass", + ]), + args(&["--device", "u1", "--identity", "sha1", "--unknown"]), + ] { + assert!(super::parse_arguments(&broken).is_err(), "{broken:?}"); + } + } + + /// The tapper's arguments write into each test target's + /// `EnvironmentVariables`, where xcodebuild hands them to the test + /// process on the device, and keep what is already there. + #[test] + fn the_tapper_environment_is_written_into_the_test_targets() { + let run = serde_json::json!({ + "TestConfigurations": [ + { + "Name": "Test Scheme Action", + "TestTargets": [ + { + "TestBundlePath": "Tapper.xctest", + "EnvironmentVariables": { "OTHER": "kept" } + } + ] + } + ] + }); + let edited = super::inject_environment( + &run, + &super::tapper_environment("hotspot-on", &["Allow", "Consenti", "OK"], 60), + ); + let variables = &edited["TestConfigurations"][0]["TestTargets"][0]["EnvironmentVariables"]; + assert_eq!(variables["OTHER"], "kept"); + assert_eq!(variables["TEST_RUNNER_ACTION"], "hotspot-on"); + assert_eq!(variables["TEST_RUNNER_LABELS"], "Allow,Consenti,OK"); + assert_eq!(variables["TEST_RUNNER_TIMEOUT"], "60"); + assert_eq!( + edited["TestConfigurations"][0]["TestTargets"][0]["TestBundlePath"], + "Tapper.xctest" + ); + let bare = serde_json::json!({ "Actionable": { "ActionableBundlePath": "x" } }); + let edited = super::inject_environment( + &bare, + &super::tapper_environment("tap-alert", &["Join"], 30), + ); + assert!(edited.get("TestConfigurations").is_none()); + assert_eq!(edited["Actionable"]["ActionableBundlePath"], "x"); + } } diff --git a/crates/connetto-test-harness/src/bin/connetto-ios-signing.rs b/crates/connetto-test-harness/src/bin/connetto-ios-signing.rs index f30462a2..ce88cd95 100644 --- a/crates/connetto-test-harness/src/bin/connetto-ios-signing.rs +++ b/crates/connetto-test-harness/src/bin/connetto-ios-signing.rs @@ -31,11 +31,16 @@ use serde_json::{Value, json}; use tokio::process::Command; const BUNDLE: &str = "dev.connetto.dioxusdemo"; -/// The two development profiles, for iOS and for this Mac. +/// The app id the tapper's test runner signs with, which `xcodebuild` +/// derives from the test bundle's id plus the `xctrunner` suffix. +const TAPPER_BUNDLE: &str = "dev.connetto.dioxusdemo.tapper.xctrunner"; +/// The three development profiles, for iOS and for this Mac, and one for +/// the tapper's runner. #[derive(Clone, Copy)] enum Profile { Ios, Mac, + Tapper, } impl Profile { @@ -43,20 +48,21 @@ impl Profile { match self { Self::Ios => "connetto dioxus demo development", Self::Mac => "connetto dioxus demo macos development", + Self::Tapper => "connetto tapper development", } } const fn kind(self) -> &'static str { match self { - Self::Ios => "IOS_APP_DEVELOPMENT", + Self::Ios | Self::Tapper => "IOS_APP_DEVELOPMENT", Self::Mac => "MAC_APP_DEVELOPMENT", } } const fn extension(self) -> &'static str { match self { - Self::Ios => "mobileprovision", Self::Mac => "provisionprofile", + Self::Ios | Self::Tapper => "mobileprovision", } } } @@ -71,6 +77,7 @@ async fn main() -> Result<()> { let keychain = Keychain::open(&dir).await?; let certificate = ensure_certificate(&api, &keychain, &dir).await?; let bundle = ensure_bundle(&api).await?; + ensure_capabilities(&api, &bundle).await?; let devices = ensure_devices(&api).await?; let profile = ensure_profile(&api, &bundle, &certificate, &devices, Profile::Ios).await?; eprintln!( @@ -78,6 +85,16 @@ async fn main() -> Result<()> { profile.display(), devices.len() ); + let tapper_bundle = ensure_tapper_bundle(&api).await?; + let profile = ensure_profile( + &api, + &tapper_bundle, + &certificate, + &devices, + Profile::Tapper, + ) + .await?; + eprintln!("profile {} covers the tapper", profile.display()); let mac = ensure_this_mac(&api).await?; let profile = ensure_profile(&api, &bundle, &certificate, &[mac], Profile::Mac).await?; eprintln!("profile {} covers this Mac", profile.display()); @@ -439,14 +456,24 @@ async fn account_certificate_for(api: &Api, key: &Path) -> Result Result { + ensure_bundle_id(api, BUNDLE, "connetto dioxus demo").await +} + +/// The id of the tapper's runner app id, registered when missing. +async fn ensure_tapper_bundle(api: &Api) -> Result { + ensure_bundle_id(api, TAPPER_BUNDLE, "connetto tapper runner").await +} + +/// The id of a bundle identifier, registered when missing. +async fn ensure_bundle_id(api: &Api, identifier: &str, name: &str) -> Result { let found = api - .get(&format!("bundleIds?filter[identifier]={BUNDLE}")) + .get(&format!("bundleIds?filter[identifier]={identifier}")) .await?; if let Some(bundle) = found["data"] .as_array() .into_iter() .flatten() - .find(|bundle| bundle["attributes"]["identifier"] == BUNDLE) + .find(|bundle| bundle["attributes"]["identifier"] == identifier) { return Ok(bundle["id"] .as_str() @@ -457,8 +484,8 @@ async fn ensure_bundle(api: &Api) -> Result { .post( "bundleIds", &json!({ "data": { "type": "bundleIds", "attributes": { - "identifier": BUNDLE, - "name": "connetto dioxus demo", + "identifier": identifier, + "name": name, "platform": "IOS", } } }), ) @@ -469,6 +496,64 @@ async fn ensure_bundle(api: &Api) -> Result { .to_owned()) } +/// The capability types the bundle id must carry so its profiles hold the +/// App Attest and Hotspot entitlements. +const CAPABILITIES: [&str; 2] = ["APP_ATTEST", "HOT_SPOT"]; + +/// Every capability the bundle id must carry, written through the API, +/// which replaces the whole set, so the existing capabilities go out with +/// the missing ones. Where the API is read-only it names the portal step, +/// since the profile takes its entitlements from the bundle id. +async fn ensure_capabilities(api: &Api, bundle: &str) -> Result<()> { + let found = api + .get(&format!("bundleIds/{bundle}/bundleIdCapabilities")) + .await?; + let mut have = std::collections::BTreeSet::new(); + for item in found["data"].as_array().into_iter().flatten() { + if let Some(kind) = item["attributes"]["capabilityType"].as_str() { + have.insert(kind.to_owned()); + } + } + let missing: Vec<&str> = CAPABILITIES + .iter() + .copied() + .filter(|capability| !have.contains(*capability)) + .collect(); + if missing.is_empty() { + return Ok(()); + } + let wanted: Vec<&str> = have + .iter() + .map(String::as_str) + .chain(CAPABILITIES.iter().copied()) + .collect(); + let data: Vec = wanted + .iter() + .map(|id| json!({ "type": "bundleIdCapabilities", "id": id })) + .collect(); + match api + .post( + &format!("bundleIds/{bundle}/relationships/bundleIdCapabilities"), + &json!({ "data": data }), + ) + .await + { + Ok(_) => { + for capability in &missing { + eprintln!("capability {capability} enabled on the bundle id"); + } + Ok(()) + } + Err(err) if err.to_string().contains("does not allow 'CREATE'") => { + eprintln!( + "the API keeps the bundle id's capabilities read-only, so enable App Attest and Personal Hotspot on the App ID `{BUNDLE}` at developer.apple.com. The profile takes the entitlements from the bundle id, and the joiner's NEHotspotConfiguration join refuses without the Hotspot one" + ); + Ok(()) + } + Err(err) => Err(err).context("writing the bundle id's capabilities"), + } +} + /// The ids of every iPhone and iPad paired with this Mac, registered when /// missing. async fn ensure_devices(api: &Api) -> Result> { @@ -495,6 +580,11 @@ async fn ensure_devices(api: &Api) -> Result> { let Some(udid) = hardware["udid"].as_str() else { continue; }; + // devicectl also lists the simulators, which the portal rejects, + // beside the physical phones. + if hardware["reality"] != "physical" { + continue; + } let name = device["deviceProperties"]["name"].as_str().unwrap_or(udid); let found = api.get(&format!("devices?filter[udid]={udid}")).await?; let id = if let Some(id) = found["data"][0]["id"].as_str() { @@ -627,9 +717,42 @@ async fn ensure_profile( tokio::fs::create_dir_all(&folder).await?; let path = folder.join(format!("{uuid}.{}", profile.extension())); tokio::fs::write(&path, content).await?; + remove_stale_profiles(&folder, profile, &path).await?; Ok(path) } +/// Remove the folder's other copies of the profile, the generations the +/// portal deleted, among which `dx` would otherwise pick one. +async fn remove_stale_profiles(folder: &Path, profile: Profile, keep: &Path) -> Result<()> { + let mut entries = tokio::fs::read_dir(folder).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path == keep + || path.extension().and_then(|ext| ext.to_str()) != Some(profile.extension()) + { + continue; + } + let Some(file) = path.to_str() else { + continue; + }; + let Ok(decoded) = run("security", &["cms", "-D", "-i", file]).await else { + continue; + }; + if profile_name(&decoded) == Some(profile.name()) { + eprintln!("removing the stale profile {}", path.display()); + tokio::fs::remove_file(&path).await?; + } + } + Ok(()) +} + +/// The `Name` of a decoded profile's property list. +fn profile_name(plist: &str) -> Option<&str> { + let (_, after) = plist.split_once("Name")?; + let (_, value) = after.split_once("")?; + value.split_once("").map(|(name, _)| name) +} + async fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { use std::os::unix::fs::PermissionsExt as _; tokio::fs::write(path, bytes) @@ -656,3 +779,23 @@ async fn run(program: &str, args: &[&str]) -> Result { } Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } + +#[cfg(test)] +mod tests { + use super::profile_name; + + #[test] + fn a_profile_name_is_the_string_after_its_key() { + let plist = "\n\tAppIDName\n\tXC dev connetto\n\tName\n\tconnetto dioxus demo development\n\tTeamName\n\tLuca\n"; + assert_eq!( + profile_name(plist), + Some("connetto dioxus demo development") + ); + } + + #[test] + fn a_plist_without_a_name_has_none() { + let plist = "\n\tAppIDName\n\tXC dev connetto\n"; + assert_eq!(profile_name(plist), None); + } +} diff --git a/crates/connetto-test-harness/src/inspector.rs b/crates/connetto-test-harness/src/inspector.rs index 74fd661c..388a6736 100644 --- a/crates/connetto-test-harness/src/inspector.rs +++ b/crates/connetto-test-harness/src/inspector.rs @@ -121,7 +121,12 @@ impl PageSession { .context("sending an inspector request")?; let deadline = Instant::now() + REPLY_BOUND; loop { - let message = next_json(&mut self.socket, deadline, &format!("request {id}")).await?; + let message = next_json( + &mut self.socket, + deadline, + &format!("request {id} ({method})"), + ) + .await?; let reply = match self.route { Route::Direct => message, Route::Target(_) => { @@ -244,8 +249,9 @@ impl PageSession { } } - /// Focus the input named `name` and type `text` into it, trusted input - /// the form's handler treats as the user's. + /// Focus the input named `name` and put `text` into it, typed as trusted + /// input over `DevTools` and set by the page with an `input` event over + /// `WebKit`. /// /// # Errors /// @@ -257,8 +263,25 @@ impl PageSession { if self.evaluate(&script).await? != true { bail!("the page has no input named {name:?}") } - self.call("Input.insertText", serde_json::json!({ "text": text })) - .await?; + match self.route { + Route::Direct => { + self.call("Input.insertText", serde_json::json!({ "text": text })) + .await?; + } + // WebKit has no `Input` domain, so the page sets the value through + // the element's own setter and raises the `input` event its + // handlers listen for. + Route::Target(_) => { + let fill = format!( + "(() => {{ const input = document.querySelector('input[name={name:?}]'); \ + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, {text:?}); \ + input.dispatchEvent(new Event('input', {{ bubbles: true }})); return true; }})()" + ); + if self.evaluate(&fill).await? != true { + bail!("the page did not fill the input named {name:?}") + } + } + } Ok(()) } } @@ -329,6 +352,71 @@ mod tests { format!("ws://{address}") } + /// A `WebKit` target endpoint, which has no `Input` domain, answering + /// `Runtime.evaluate` with `true` and recording each expression on + /// `seen`. + async fn webkit_endpoint(seen: tokio::sync::mpsc::UnboundedSender) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + let announce = serde_json::json!({ + "method": "Target.targetCreated", + "params": { "targetInfo": { "targetId": "page-1" } }, + }); + socket + .send(Message::Text(announce.to_string())) + .await + .unwrap(); + while let Some(Ok(Message::Text(text))) = socket.next().await { + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + let inner: serde_json::Value = + serde_json::from_str(frame["params"]["message"].as_str().unwrap()).unwrap(); + let reply = if inner["method"] == "Runtime.evaluate" { + let _ = seen.send(inner["params"]["expression"].as_str().unwrap().to_owned()); + serde_json::json!({ + "id": inner["id"], + "result": { "result": { "type": "boolean", "value": true } }, + }) + } else { + serde_json::json!({ + "id": inner["id"], + "error": { "code": -32601, "message": "'Input' domain was not found" }, + }) + }; + let wrapped = serde_json::json!({ + "method": "Target.dispatchMessageFromTarget", + "params": { "targetId": "page-1", "message": reply.to_string() }, + }); + socket + .send(Message::Text(wrapped.to_string())) + .await + .unwrap(); + } + }); + format!("ws://{address}") + } + + /// `WebKit` has no `Input` domain, so a field is filled through the page, + /// the value set and an `input` event raised for the page's handlers. + #[tokio::test] + async fn a_webkit_field_is_filled_through_the_page() { + let (seen, mut expressions) = tokio::sync::mpsc::unbounded_channel(); + let url = webkit_endpoint(seen).await; + let mut page = PageSession::webkit(&url).await.unwrap(); + page.set_input("ssid", "iPhone di Luca").await.unwrap(); + let mut all = Vec::new(); + while let Ok(expression) = expressions.try_recv() { + all.push(expression); + } + assert!( + all.iter() + .any(|e| e.contains("\"iPhone di Luca\"") && e.contains("'input'")), + "{all:?}" + ); + } + #[tokio::test] async fn a_wait_outlasts_the_page_navigating_under_it() { let url = endpoint(vec![ diff --git a/crates/connetto-test-harness/src/lib.rs b/crates/connetto-test-harness/src/lib.rs index b4acc757..2975c4cd 100644 --- a/crates/connetto-test-harness/src/lib.rs +++ b/crates/connetto-test-harness/src/lib.rs @@ -66,6 +66,8 @@ pub mod demo; pub mod fanout; pub mod inspector; pub mod ios_signing; +pub mod peer_devices; +pub mod peer_proof; pub mod relay; pub mod roster; pub mod stack; diff --git a/crates/connetto-test-harness/src/peer_devices.rs b/crates/connetto-test-harness/src/peer_devices.rs new file mode 100644 index 00000000..4ed89669 --- /dev/null +++ b/crates/connetto-test-harness/src/peer_devices.rs @@ -0,0 +1,86 @@ +//! The two-phone peer proof's phone-driving steps (R76), which read a host's +//! offer off its page and the page's own identity, and wait for the link's +//! end. + +use std::time::Duration; + +use anyhow::{Context as _, Result, bail}; +use tokio::time::{Instant, sleep}; + +use crate::inspector::PageSession; +use crate::peer_proof::{HotspotOfferLines, parse_offer_lines}; + +/// How long both pages get to drop their linked lines after a leave. +pub const LINK_END_BOUND: Duration = Duration::from_secs(120); + +/// The offer a hosting phone shows, its lines read off the page, bounded. +/// +/// # Errors +/// +/// When the page does not show the whole offer within its bound, or does not +/// answer. +pub async fn read_hotspot_offer(app: &mut PageSession) -> Result { + app.wait_for_text("hotspot ssid: ", Duration::from_secs(45)) + .await + .context("the host never showed its offer")?; + let deadline = Instant::now() + Duration::from_secs(45); + loop { + let page = app.page_text().await?; + if let Some(offer) = parse_offer_lines(&page) { + return Ok(offer); + } + if Instant::now() >= deadline { + bail!("the offer's lines never finished showing, the page shows:\n{page}"); + } + sleep(Duration::from_millis(500)).await; + } +} + +/// The page's own peer identity, `account/key prefix`, from its listening +/// line, which the wait polls until the line carries it. +/// +/// # Errors +/// +/// When the page names no peer identity within its bound, or does not +/// answer. +pub async fn own_identity(app: &mut PageSession) -> Result { + // The peer panel updates the listening line on its own poll, which the + // device label does not wait for, so the wait polls the line itself. + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let page = app.page_text().await?; + if let Some(identity) = page + .lines() + .find_map(|line| line.strip_prefix("peer: listening on ")) + .and_then(|rest| rest.split_once(" as ")) + .map(|(_, identity)| identity.trim().to_owned()) + { + return Ok(identity); + } + if Instant::now() >= deadline { + bail!("the page names no peer identity"); + } + sleep(Duration::from_millis(500)).await; + } +} + +/// Wait for both pages to drop their linked lines, which the leave and stop +/// end the link. +/// +/// # Errors +/// +/// When a link line stays past [`LINK_END_BOUND`], or a page does not answer. +pub async fn wait_link_ends(app: &mut PageSession, peer_app: &mut PageSession) -> Result<()> { + let deadline = Instant::now() + LINK_END_BOUND; + loop { + let host_page = app.page_text().await?; + let peer_page = peer_app.page_text().await?; + if !host_page.contains("linked: ") && !peer_page.contains("linked: ") { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("the link did not end after the phones left the hotspot"); + } + sleep(Duration::from_secs(2)).await; + } +} diff --git a/crates/connetto-test-harness/src/peer_proof.rs b/crates/connetto-test-harness/src/peer_proof.rs new file mode 100644 index 00000000..0b850286 --- /dev/null +++ b/crates/connetto-test-harness/src/peer_proof.rs @@ -0,0 +1,222 @@ +//! The parts of the Android and iOS two-phone peer proofs (R76) that run +//! without a phone, which are the offer's lines off a page's text, the run's +//! commit and its summary. The phone-driving steps live in +//! [`peer_devices`](crate::peer_devices). + +use std::time::Duration; + +use anyhow::Result; +use tokio::process::Command; + +/// The offer a hosting phone shows, its lines read off the page. +pub struct HotspotOfferLines { + /// The hotspot's ssid. + pub ssid: String, + /// The hotspot's passphrase. + pub passphrase: String, + /// The host's peer port. + pub port: u16, + /// The offer as one `WIFI:` payload, where the page shows it. + pub payload: Option, +} + +/// The offer's lines off a page's text, all three present and the port a +/// number, or none while they are not. +pub fn parse_offer_lines(page: &str) -> Option { + let line = |label: &str| -> Option { + page.lines() + .find_map(|line| line.strip_prefix(label).map(|rest| rest.trim().to_owned())) + }; + let ssid = line("hotspot ssid: ")?; + let passphrase = line("hotspot passphrase: ")?; + let port = line("hotspot port: ")?; + if port == "none" { + return None; + } + Some(HotspotOfferLines { + ssid, + passphrase, + port: port.parse::().ok()?, + payload: line("hotspot payload: "), + }) +} + +/// The commit the run built, marked `-dirty` over local changes, and +/// `unknown` where git does not answer. +pub async fn commit() -> String { + let Ok(root) = crate::stack::repo_path(&[]) else { + return "unknown".to_owned(); + }; + match Command::new("git") + .arg("-C") + .arg(root) + .args(["describe", "--always", "--dirty", "--abbrev=40"]) + .output() + .await + { + Ok(output) if output.status.success() => { + String::from_utf8_lossy(&output.stdout).trim().to_owned() + } + _ => "unknown".to_owned(), + } +} + +/// The run's summary: the commit, the single-phone proof, each join with +/// its time and, where it failed, why, and the restore after the joins +/// where it failed. +pub fn summary( + commit: &str, + (proof_took, proof): &(Duration, Result<(), String>), + joins: Result<&[JoinReport], &anyhow::Error>, + restore: Result<(), &anyhow::Error>, +) -> String { + use std::fmt::Write as _; + let line = |name: &str, took: Duration, outcome: Result<(), String>| match outcome { + Ok(()) => format!("{name}: passed in {}s\n", took.as_secs()), + Err(err) => format!("{name}: failed in {}s: {err}\n", took.as_secs()), + }; + let mut text = format!("commit: {commit}\n"); + text += &line("single-phone proof", *proof_took, proof.clone()); + match joins { + Ok(reports) => { + for report in reports { + let name = format!("{} join", report.name); + match (report.took, &report.outcome) { + (Some(took), outcome) => { + text += &line( + &name, + took, + outcome.as_ref().map(drop).map_err(|err| format!("{err:#}")), + ); + } + (None, Err(err)) => { + let _ = writeln!(text, "{name}: not run: {err:#}"); + } + (None, Ok(())) => { + let _ = writeln!(text, "{name}: not run"); + } + } + } + } + Err(err) => { + let _ = writeln!(text, "joins: not run, the setup failed: {err:#}"); + } + } + if let Err(err) = restore { + let _ = writeln!(text, "restore: failed: {err:#}"); + } + text +} + +/// One join's result, for the run's summary. +pub struct JoinReport { + /// The join's name, as the summary and the evidence show it. + pub name: &'static str, + /// How long the join ran, or `None` for a join never started. + pub took: Option, + /// How it ended. + pub outcome: Result<()>, +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{JoinReport, parse_offer_lines, summary}; + + /// The summary lists the commit, the single-phone proof, and each join + /// with its time and, where it failed, why. + #[test] + fn the_summary_names_every_step_and_why_a_join_failed() { + let reports = [ + JoinReport { + name: "form", + took: Some(Duration::from_secs(201)), + outcome: Ok(()), + }, + JoinReport { + name: "beacon", + took: Some(Duration::from_secs(95)), + outcome: Err(anyhow::anyhow!( + "the second phone never saw the first phone's beacon" + )), + }, + JoinReport { + name: "payload", + took: None, + outcome: Err(anyhow::anyhow!("the beacon join's cleanup failed")), + }, + ]; + assert_eq!( + summary( + "c0626b2", + &(Duration::from_secs(180), Ok(())), + Ok(&reports), + Ok(()) + ), + "commit: c0626b2\n\ + single-phone proof: passed in 180s\n\ + form join: passed in 201s\n\ + beacon join: failed in 95s: the second phone never saw the first phone's beacon\n\ + payload join: not run: the beacon join's cleanup failed\n" + ); + let setup = anyhow::anyhow!("devicectl install exited with 1"); + assert_eq!( + summary( + "c0626b2", + &(Duration::from_secs(180), Ok(())), + Err(&setup), + Ok(()) + ), + "commit: c0626b2\n\ + single-phone proof: passed in 180s\n\ + joins: not run, the setup failed: devicectl install exited with 1\n" + ); + } + + /// Joins that ran keep their lines when the run's own restore fails after + /// them, and the restore's failure gets its own line. + #[test] + fn a_failed_restore_keeps_the_joins_that_ran() { + let reports = [JoinReport { + name: "form", + took: Some(Duration::from_secs(201)), + outcome: Ok(()), + }]; + let restore = anyhow::anyhow!("the browser role did not return"); + assert_eq!( + summary( + "c0626b2", + &(Duration::from_secs(180), Ok(())), + Ok(&reports), + Err(&restore) + ), + "commit: c0626b2\n\ + single-phone proof: passed in 180s\n\ + form join: passed in 201s\n\ + restore: failed: the browser role did not return\n" + ); + } + + /// The offer's lines parse off a page's text, and an unfinished offer + /// stays none. + #[test] + fn the_offer_lines_are_read_off_the_page() { + let page = "\npeer: listening on 41234\n\ + hotspot ssid: iPhone di Luca\n\ + hotspot passphrase: s3cr3t pass\n\ + hotspot port: 41234\n\ + hotspot payload: WIFI:T:WPA;S:iPhone di Luca;P:s3cr3t pass;X:41234;;\n\ + hotspot: hosting\n"; + let offer = parse_offer_lines(page).expect("all three lines are there"); + assert_eq!(offer.ssid, "iPhone di Luca"); + assert_eq!(offer.passphrase, "s3cr3t pass"); + assert_eq!(offer.port, 41234); + assert_eq!( + offer.payload.as_deref(), + Some("WIFI:T:WPA;S:iPhone di Luca;P:s3cr3t pass;X:41234;;") + ); + assert!(parse_offer_lines("hotspot ssid: only\n").is_none()); + assert!(parse_offer_lines("hotspot port: none\n").is_none()); + } +} diff --git a/crates/connetto-web/Cargo.lock b/crates/connetto-web/Cargo.lock index ca317176..f49b693e 100644 --- a/crates/connetto-web/Cargo.lock +++ b/crates/connetto-web/Cargo.lock @@ -394,6 +394,7 @@ dependencies = [ "diesel", "diesel-sqlite-session", "getrandom 0.3.4", + "if-addrs", "ipnet", "js-sys", "libsqlite3-sys", @@ -1290,6 +1291,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "impartial-ord" version = "2.0.0" diff --git a/examples/dioxus-desktop-demo/Cargo.lock b/examples/dioxus-desktop-demo/Cargo.lock index 4f809420..31157ea1 100644 --- a/examples/dioxus-desktop-demo/Cargo.lock +++ b/examples/dioxus-desktop-demo/Cargo.lock @@ -1219,12 +1219,14 @@ dependencies = [ "connetto-core", "connetto-peer", "connetto-peer-android", + "connetto-peer-apple", "connetto-schema", "core-foundation", "diesel", "diesel-sqlite-session", "futures-util", "getrandom 0.3.4", + "if-addrs", "ipnet", "jni 0.21.1", "js-sys", @@ -1329,6 +1331,7 @@ dependencies = [ "tempfile", "tokio", "tracing", + "tracing-subscriber", ] [[package]] @@ -1420,6 +1423,24 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "connetto-peer-apple" +version = "0.0.0" +dependencies = [ + "block2", + "connetto-peer", + "dispatch2", + "if-addrs", + "libc", + "objc2", + "objc2-core-bluetooth", + "objc2-foundation", + "objc2-network-extension", + "objc2-ui-kit", + "parking_lot", + "thiserror 2.0.20", +] + [[package]] name = "connetto-schema" version = "0.0.0" @@ -5555,6 +5576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79b30b9eacc37434a61377866f68b27acef9fa5496f25cc9ca8b2549032e0394" dependencies = [ "bitflags 2.13.2", + "dispatch2", "objc2", "objc2-foundation", ] @@ -5628,6 +5650,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-network-extension" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9126ecdaea052ce2a6b7a894303d0806368b92c77165052da1c3eb52d9e5f9b1" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-system-configuration" version = "0.3.2" diff --git a/examples/dioxus-desktop-demo/Cargo.toml b/examples/dioxus-desktop-demo/Cargo.toml index d1f0bc98..34a952d2 100644 --- a/examples/dioxus-desktop-demo/Cargo.toml +++ b/examples/dioxus-desktop-demo/Cargo.toml @@ -44,6 +44,8 @@ base64 = "0.22" robius-directories = "6" # Sign-in through the platform's in-app browser tab and the app's own redirect. connetto-auth-session = { path = "../../crates/connetto-auth-session" } +# The writer that sends a phone build's log to stdout and its log file. +tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] } # Windows Hello prompts over the demo's window (R53). [target.'cfg(target_os = "windows")'.dependencies] diff --git a/examples/dioxus-desktop-demo/Dioxus.toml b/examples/dioxus-desktop-demo/Dioxus.toml index 26d91eb5..ecf56cd5 100644 --- a/examples/dioxus-desktop-demo/Dioxus.toml +++ b/examples/dioxus-desktop-demo/Dioxus.toml @@ -5,12 +5,24 @@ identifier = "dev.connetto.dioxusdemo" # The gate on the stored secrets (R51, R52). iOS offers Face ID only with the # usage description this writes, and Android needs `USE_BIOMETRIC` for the -# unlock prompt. +# unlock prompt. The peer link (R76) scans for and connects to nearby hosts +# over Bluetooth and finds and links to devices on the local network. [permissions] biometrics = { description = "Unlock your synced data" } +bluetooth = { description = "Find and link to nearby devices" } +local_network = { description = "Find and link to nearby devices" } + +# The mDNS service the peer discovery advertises and browses (R76). +[ios.plist] +NSBonjourServices = ["_connetto-peer._tcp"] # App Attest vouches for this build at device enrolment (R74 decisions 32 and -# 35). The development profile must carry the App Attest capability, which -# `connetto-ios-signing` writes once it is enabled on the App ID. +# 35). The development profile takes the entitlements from the App ID's +# capabilities, which a human enables on the App ID, since the API keeps the +# set read-only and `connetto-ios-signing` only names the portal step. The +# Hotspot entitlement is required, since the joiner's +# `NEHotspotConfiguration` join of the Personal Hotspot of the iPhone or +# iPad host refuses without it (R76). [ios.entitlements] "com.apple.developer.devicecheck.appattest-environment" = "development" +"com.apple.developer.networking.HotspotConfiguration" = true diff --git a/examples/dioxus-desktop-demo/src/main.rs b/examples/dioxus-desktop-demo/src/main.rs index 4838d5ff..efe0a7fc 100644 --- a/examples/dioxus-desktop-demo/src/main.rs +++ b/examples/dioxus-desktop-demo/src/main.rs @@ -241,6 +241,35 @@ fn app_data_root() -> PathBuf { ) } +/// The log a phone build writes beside stdout, in the app's data directory, +/// since a phone keeps no app's stdout and a device proof copies this file +/// off the phone. +#[cfg(any(target_os = "android", target_os = "ios"))] +const PHONE_LOG: &str = "connetto-demo.log"; + +/// Log to stdout and to [`PHONE_LOG`], or to stdout alone where the file +/// cannot be opened. `RUST_LOG` overrides the default filter. +#[cfg(any(target_os = "android", target_os = "ios"))] +fn init_phone_logging() { + use tracing_subscriber::fmt::writer::MakeWriterExt as _; + + let directives = std::env::var("RUST_LOG").unwrap_or_else(|_| LOG_FILTER.to_owned()); + let root = app_data_root(); + let file = std::fs::create_dir_all(&root).and_then(|()| { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(root.join(PHONE_LOG)) + }); + match file { + Ok(file) => connetto_core::logging::install( + std::io::stdout.and(std::sync::Mutex::new(file)), + &directives, + ), + Err(_) => connetto_core::logging::install(std::io::stdout, &directives), + } +} + fn export_path() -> PathBuf { data_dir().join("connetto-local-data.zip") } @@ -263,6 +292,9 @@ fn publish_export(part: &Path) -> std::io::Result { const LOG_FILTER: &str = "info,connetto_client::away=debug,connetto_dioxus::away=debug"; fn main() { + #[cfg(any(target_os = "android", target_os = "ios"))] + init_phone_logging(); + #[cfg(not(any(target_os = "android", target_os = "ios")))] connetto_core::logging::init_stdout_with_default(LOG_FILTER); // Leaked so it outlives `launch`: every session's tasks run on it for the // life of the process. @@ -758,6 +790,11 @@ fn PeerPanel(parts: SessionParts) -> Element { let mut join_port: Signal = use_signal(String::new); // A pasted or scanned `WIFI:` payload (R76 decision 24). let mut join_payload: Signal = use_signal(String::new); + // The personal hotspot's form, the network name pre-filled from the + // device's user-set name where the platform answers one (R76 decision 26). + let mut personal_ssid: Signal = + use_signal(|| parts.0.native.personal_hotspot_name().unwrap_or_default()); + let mut personal_passphrase: Signal = use_signal(String::new); // The link's and the hotspot's events, which the panel owns. let client = parts.0.client.clone(); @@ -807,6 +844,9 @@ fn PeerPanel(parts: SessionParts) -> Element { held.retain(|known| known.host != host); nearby.set(held); } + ClientEvent::HotspotUnreached => { + outcome.set("unreached".to_owned()); + } _ => {} } } @@ -845,6 +885,9 @@ fn PeerPanel(parts: SessionParts) -> Element { let nearby_parts = parts.clone(); let nearby_outcome = outcome; let nearby_hosts = nearby; + let personal_parts = parts.clone(); + let personal_offer = offer; + let mut personal_outcome = outcome; let me = parts .0 @@ -859,6 +902,10 @@ fn PeerPanel(parts: SessionParts) -> Element { let nearby_lines: Vec = nearby().iter().map(NearbyHost::line).collect(); let offer = offer(); let outcome = outcome(); + #[cfg(target_os = "ios")] + let personal_note: Option<&str> = Some("connetto cannot see whether Personal Hotspot is on"); + #[cfg(not(target_os = "ios"))] + let personal_note: Option<&str> = None; rsx! { div { @@ -869,6 +916,9 @@ fn PeerPanel(parts: SessionParts) -> Element { for line in peers.iter().cloned() { p { {line} } } + if let Some(note) = personal_note { + p { {note} } + } for line in nearby_lines { p { {line} } } @@ -942,6 +992,59 @@ fn PeerPanel(parts: SessionParts) -> Element { }, "Stop the hotspot" } + if cfg!(target_os = "ios") { + input { + r#type: "text", + name: "personal-ssid", + placeholder: "the personal hotspot's network name", + value: "{personal_ssid}", + oninput: move |event| personal_ssid.set(event.value()), + style: "font-family: monospace;", + } + input { + r#type: "text", + name: "personal-passphrase", + placeholder: "the personal hotspot's passphrase", + value: "{personal_passphrase}", + oninput: move |event| personal_passphrase.set(event.value()), + style: "font-family: monospace;", + } + button { + onclick: move |_| { + let ssid = personal_ssid.peek().trim().to_owned(); + let passphrase = personal_passphrase.peek().clone(); + if ssid.is_empty() || passphrase.is_empty() { + personal_outcome + .set("the network name and the passphrase need filling".to_owned()); + return; + } + let parts = personal_parts.clone(); + let mut offer = personal_offer; + let mut outcome = personal_outcome; + spawn(async move { + outcome.set("asking for the personal hotspot".to_owned()); + let result = + parts.0.native.host_personal_hotspot(&ssid, &passphrase).await; + match result { + Ok(hosted) => { + offer.set(Some(OfferLines { + ssid: hosted.offer.ssid.clone(), + passphrase: hosted.offer.passphrase.clone(), + port: hosted.offer.port, + payload: hosted.offer.to_wifi_payload().to_string(), + })); + outcome.set(match hosted.beacon { + Ok(()) => "hosting".to_owned(), + Err(err) => format!("hosting, the beacon is off: {err}"), + }); + } + Err(err) => outcome.set(err.to_string()), + } + }); + }, + "Host my Personal Hotspot" + } + } input { r#type: "text", name: "ssid", @@ -1079,8 +1182,11 @@ fn PeerPanel(parts: SessionParts) -> Element { spawn(async move { outcome.set("joining the nearby host".to_owned()); match parts.0.native.join_nearby(&host).await { - Ok(gateway) => { - outcome.set(format!("joined through the gateway {gateway}")); + Ok((identity, gateway)) => { + outcome.set(format!( + "joined through the gateway {gateway} as {}", + short_identity(&identity) + )); } Err(err) => outcome.set(err.to_string()), } @@ -1091,24 +1197,30 @@ fn PeerPanel(parts: SessionParts) -> Element { } } } - /// A host whose beacon this device sees (R76). #[cfg(feature = "peer")] #[derive(Clone, PartialEq)] struct NearbyHost { host: connetto_client::HostId, - prefix: [u8; 8], + /// The beacon's prefix, where the beacon carries one (R76 decision 25). + prefix: Option<[u8; 8]>, rssi: Option, } #[cfg(feature = "peer")] impl NearbyHost { - /// The panel's line, `nearby: ` with the signal where known. + /// The panel's line, `nearby: ` or `nearby: no beacon prefix` + /// where the beacon carries none, with the signal where known. fn line(&self) -> String { use std::fmt::Write as _; let mut line = String::from("nearby: "); - for byte in self.prefix { - let _ = write!(line, "{byte:02x}"); + match self.prefix { + Some(prefix) => { + for byte in prefix { + let _ = write!(line, "{byte:02x}"); + } + } + None => line.push_str("no beacon prefix"), } if let Some(rssi) = self.rssi { let _ = write!(line, " at {rssi} dBm"); diff --git a/examples/dioxus-web-demo/Cargo.lock b/examples/dioxus-web-demo/Cargo.lock index 2940feb4..aa524047 100644 --- a/examples/dioxus-web-demo/Cargo.lock +++ b/examples/dioxus-web-demo/Cargo.lock @@ -528,6 +528,7 @@ dependencies = [ "diesel", "diesel-sqlite-session", "getrandom 0.3.4", + "if-addrs", "ipnet", "js-sys", "libsqlite3-sys", @@ -2292,6 +2293,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "impartial-ord" version = "2.0.0" diff --git a/examples/wasm-smoke/Cargo.lock b/examples/wasm-smoke/Cargo.lock index c7e1eec9..8233d933 100644 --- a/examples/wasm-smoke/Cargo.lock +++ b/examples/wasm-smoke/Cargo.lock @@ -394,6 +394,7 @@ dependencies = [ "diesel", "diesel-sqlite-session", "getrandom 0.3.4", + "if-addrs", "ipnet", "js-sys", "libsqlite3-sys", @@ -1348,6 +1349,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "impartial-ord" version = "2.0.0" diff --git a/examples/webauthn-unlock/Cargo.lock b/examples/webauthn-unlock/Cargo.lock index 9b62b31d..1c7ac8bb 100644 --- a/examples/webauthn-unlock/Cargo.lock +++ b/examples/webauthn-unlock/Cargo.lock @@ -429,6 +429,7 @@ dependencies = [ "diesel", "diesel-sqlite-session", "getrandom 0.3.4", + "if-addrs", "ipnet", "js-sys", "libsqlite3-sys", @@ -1355,6 +1356,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "impartial-ord" version = "2.0.0" diff --git a/examples/yew-web-demo/Cargo.lock b/examples/yew-web-demo/Cargo.lock index 48a5da9f..13daa2e7 100644 --- a/examples/yew-web-demo/Cargo.lock +++ b/examples/yew-web-demo/Cargo.lock @@ -403,6 +403,7 @@ dependencies = [ "diesel", "diesel-sqlite-session", "getrandom 0.3.4", + "if-addrs", "ipnet", "js-sys", "libsqlite3-sys", @@ -1596,6 +1597,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "impartial-ord" version = "2.0.0" diff --git a/plans/master-implementation-plan.md b/plans/master-implementation-plan.md index 0cd3a359..85d3ef20 100644 --- a/plans/master-implementation-plan.md +++ b/plans/master-implementation-plan.md @@ -5316,6 +5316,10 @@ The local data plane R25 decided, discovery by gateway-probe first (a hotspot ho 22. **`connetto-client` implements the btleplug central for every system, and `connetto-peer-android` only bridges Android's virtual machine to it** (2026-10-09). btleplug's Android `init` takes a jni 0.22 environment, connetto and manganis stand on jni 0.21, and jni 0.22 learns the running virtual machine only through the `unsafe` `JavaVM::from_raw`. So `connetto-peer-android` offers a safe `java_vm()` building it from the virtual machine manganis hands out, its one `unsafe` call documented under the crate's own lints as decision 17 places it, and ships btleplug's Java in its Gradle module. `connetto-client` depends on it on Android only and runs btleplug on one dedicated thread, attached, its class loader set to the application's, `init` called once, and its own runtime, and the polled `CentralBackend` reaches that thread over channels. Slice 8's other systems run the same central without the bridge. The central inside `connetto-peer-android`, handed to the builder by the application, was rejected, since it would keep Android's copy apart from every other system's and cost every application a line. The central starts when Bluetooth first reads `Ready`, never while the client is built (decided with the maintainer 2026-10-11). On iPhone and iPad btleplug's start waits for the system's Bluetooth state, which arrives only once the user answers the permission alert, so a start while the client was built passed its 10-second bound whenever the alert waited longer and left the joiner without a central until the application restarted. A longer bound tried once was rejected, since an alert answered after it still left no central. 23. **The QR code carries the standard `WIFI:` fields and the peer port, and the application draws and scans it** (2026-10-09). The payload is `WIFI:T:;S:;P:;X:;;` with the format's backslash escapes, so a phone's system camera joins the Wi-Fi and skips `X`, which parsers of the format pass over, and an application that scans the code or takes the string learns the port and dials the gateway at once (decision 11). `HotspotOffer::to_wifi_payload` writes it and `HotspotOffer::from_wifi_payload` reads it with a typed refusal, and the application renders and scans the code with its own tools. A plain `WIFI:` code was rejected, since a device joining through its application would lose the port, and so was a QR renderer inside connetto, which every application's own toolkit already has. 24. **The text form is the offer's three lines, and the payload travels as one string** (2026-10-09). A person on a machine without a camera types the network and password into the system's own settings, which ask for exactly those, and the port into the application, while a chat message or a scanner carries the payload whole into `from_wifi_payload`. A readable sentence was rejected, since its wording needs translation and belongs to the application. +25. **An iPhone or iPad host advertises connetto's service UUID alone, and a user-facing name comes only after the exchange** (2026-10-09). Apple lets an application's advert carry a local name and service UUIDs only, so decision 18's service data is out on iOS, and an iPhone's beacon is the UUID with no prefix. A joiner keys such a host by its Bluetooth address, which iOS rotates about every 15 minutes, so the same iPhone can reappear as a new nearby host and autojoin tries it once more. `HostNearby` carries the prefix where the beacon has one. No beacon carries a name, since the beacon is public (decision 3) and a name in it would tell anyone nearby who hosts. `fetch_offer` and `join_nearby` answer the host identity the handshake proved, so an application can ask "Join Luca's iPhone?" from its own account names before it joins. The prefix in the local name was rejected, since the cleaner advert outweighs keeping one key per iPhone across address rotations. +26. **An iPhone or iPad host's network name is typed by default and prefilled where the application holds Apple's device-name entitlement** (2026-10-09). Personal Hotspot names its network after the device and iOS gives no application its password, so `host_personal_hotspot(ssid, passphrase)` takes both. Since iOS 16 `UIDevice`'s name is a generic "iPhone" unless the application holds `com.apple.developer.device-information.user-assigned-device-name`, which Apple grants per application, so connetto answers the real name through `personal_hotspot_name()` where the running application holds it and `None` elsewhere, and the demo prefills it once Apple grants the demo's request. Waiting for the entitlement was rejected, since every application without it still needs the typed name. +27. **An iPhone or iPad host reports an offer no joiner landed on and leaves the retry to the people involved** (2026-10-09). iOS tells an application nothing about Personal Hotspot, whether it is on, who joined or whether the typed password is right. Measured on the iPhone 15 Pro Max on iOS 26, `bridge100` at 172.20.10.1 exists exactly while a device is joined to the hotspot, and switching Personal Hotspot on with nobody joined shows no interface. So when an exchange hands the offer out and no device lands on `bridge100` within the join bound, the host emits `ClientEvent::HotspotUnreached`, and the application tells its user, who checks Personal Hotspot and the password, after which the joiner tries again. The beacon keeps advertising. Stopping the beacon on a silent offer was rejected, since a joiner that declined or was slow would turn a working hotspot off. +28. **An Apple joiner asks the system for the network once more when its first join lands nowhere** (decided with the maintainer 2026-10-12). iOS can refuse to join a Personal Hotspot switched on seconds earlier, showing its own "unable to join" alert while `applyConfiguration` still answers success, so the device stays on its previous network. When the system has answered and the interface has not moved off the address it stood on before the join after 15 seconds, the joiner applies the configuration a second time, once per join, within the join table's 60-second bound. A proof that waits before each join was rejected, since a person scanning a code at once meets the same refusal. ### Design @@ -5327,6 +5331,20 @@ The local data plane R25 decided, discovery by gateway-probe first (a hotspot ho - **Beacon and exchange.** While the device both hosts a hotspot and serves its peer link, it advertises the beacon of decision 18 and runs a GATT service under connetto's service UUID, with one characteristic the joiner writes and one it reads notifications from. The exchange runs the link's TLS 1.3 with the same verifier over frames split to the negotiated packet size, swaps `Hello` frames and the lists either side lacks into the one intake, and then the host sends `Offer { ssid, passphrase, security, port }`, the hotspot's details with the peer port, and closes. Every exchange is bounded at 30 seconds, at most 4 run at once on a host, and a fifth connection is refused at once. Android needs `BLUETOOTH_ADVERTISE`, `BLUETOOTH_CONNECT` and `BLUETOOTH_SCAN` (with `neverForLocation`), which `connetto-peer-android`'s manifest declares, and Bluetooth `Ready` as decision 21 states it. +The iPhone or iPad host against its state (decisions 25 to 27), beside the beacon table, which it follows for the beacon's own rows with the UUID-only advert. + +| Event | Idle | Hosting(offer) | +| --- | --- | --- | +| `host_personal_hotspot(ssid, passphrase)` while the device serves | become Hosting, the beacon per the beacon table | replace the offer, the beacon unchanged | +| `host_personal_hotspot` while the device does not serve | become Hosting with no beacon, the offer for text and QR (decision 14) | replace the offer | +| An exchange hands the offer out | not applicable | start the landing bound of the join bound | +| A device lands on `bridge100` within the landing bound | not applicable | end the bound | +| The landing bound passes with no device on `bridge100` | not applicable | emit `HotspotUnreached`, the beacon keeps advertising | +| Personal Hotspot switched off in Settings, or a wrong password typed | invisible | invisible until a landing bound passes | +| The application moves to the background | nothing | iOS moves the UUID to its overflow area, which Apple scanners filtering on it still read and others do not | +| `stop_hotspot` | nothing | become Idle, the beacon stops | +| Client closed | nothing | become Idle, the beacon stops | + The host's beacon against its state, beside the hotspot tables. | Event | Off | Advertising | @@ -5350,7 +5368,7 @@ The joiner's scan and exchange against its state. Scanning runs while scan is on | --- | --- | --- | --- | | Scan or autojoin turned on while the device serves with no joined hotspot | start scanning, become Scanning | nothing | nothing | | Scan turned off, the device stops serving, or a hotspot is joined | nothing | stop scanning, emit `HostGone` for every reported host, become Idle | finish the exchange, then become Idle | -| A beacon with connetto's UUID is seen | not applicable | a new prefix emits `HostNearby`, a known one refreshes its signal | remember it | +| A beacon with connetto's UUID is seen | not applicable | a new host, by its prefix where the beacon carries one and else by its Bluetooth address (decision 25), emits `HostNearby`, a known one refreshes its signal | remember it | | A host's beacon is unseen for 30 seconds | not applicable | emit `HostGone`, forget it | same | | Autojoin is on and a host not yet tried is seen | not applicable | pick the strongest untried host, become Exchanging | wait | | `join_nearby(host)` or `fetch_offer(host)` while Bluetooth is not `Ready` | decision 21's action runs once unless the prompt is switched off, then go on as below or answer `BluetoothOff` or `BluetoothNotPermitted` | same | answer `Busy` | @@ -5479,7 +5497,12 @@ One pull request each, in this order, each green and useful on its own (2026-10- 6. **The Android central** (step 4's Android joiner part, decision 20). The backend trait over btleplug 0.13.5, with btleplug's Java in `connetto-peer-android`. Proven on emi between the two A35s, one hosting and the other joining through the beacon and exchange. **Built (R76, 2026-10-09)** as `connetto-client`'s `BtleplugCentral` over btleplug 0.13.5, with `connetto-peer-android`'s `java_vm` and `use_application_class_loader` and btleplug's Java in its Gradle module (decision 22). On emi the Android 14 A35 saw the Android 15 one's beacon on its panel, joined through `Join the nearby host`, the exchange handing over the offer, the runner tapping the approval, and both phones named each other linked. Measured on the way: the joiner's chunk is the smaller of the packet less its 3-byte header and the 512 bytes GATT allows an attribute value, since Android refuses a longer notification even at an MTU of 517, and a joiner holds no Bluetooth central where btleplug's thread does not start, so it neither scans nor joins there. 7. **The text and QR fallback** (step 5, decisions 23 and 24). The `WIFI:` payload, its parser and the offer's three lines, the demo showing the code and the copyable payload and joining from a pasted one, proven on emi by a host with Bluetooth off whose payload the other A35 pastes and links through, the camera scan left to the home run. **Built (R76, 2026-10-09)** as `HotspotOffer::to_wifi_payload` and `from_wifi_payload` with `WifiPayloadError`, the demo drawing the payload with the `qrcode` crate beside its copyable line and joining from a pasted one. On emi the Android 15 A35 hosted with its Bluetooth off, the runner declined the system's enable dialog so the call answered the offer with its beacon off, the Android 14 one joined from the pasted payload, and both phones named each other linked. The reader requires the closing `;;`, so a payload cut short in a message is refused and never read with a shortened password. 8. **The on-demand run** (step 7's first half, decision 4). `--every-join` on `connetto-android-proof`, every join in one run on the same signed-in phones, with its summary. Proven by one run on the two A35s with every join green. **Built (R76, 2026-10-09)**: one run on the A35s passed the single-phone proof in 106 s and the form, beacon and payload joins in 21 s, 31 s and 21 s, with both phones back at rest after each and the screenshots named by their join. A join that is not the beacon's declines the system's Bluetooth dialog on the host, since hosting with Bluetooth off opens it (decision 21). -9. **Every other machine** (step 3, 4 and 6 for Windows, Linux, macOS, iPhone and iPad, and step 7's home run). Split further when it starts. +9. **Every other machine** (step 3, 4 and 6 for Windows, Linux, macOS, iPhone and iPad, and step 7's home run), split one pull request per system and ordered handhelds first, since the feature is above all for handheld devices where the network is poor or absent (decided with the maintainer 2026-10-09), each ending with an end-to-end join on its own machine. + - 9a. **iPhone and iPad**, one pull request covering both halves, since the devices at hand are an iPhone, an iPad and the Mac in one place, and the only end-to-end pair among them is the iPhone hosting and the iPad joining (decided with the maintainer 2026-10-09). Joining is the btleplug central over CoreBluetooth, `NEHotspotConfiguration` behind the system's join alert, and the gateway dial under iOS's local-network permission. Hosting is the typed Personal Hotspot and the beacon through `CBPeripheralManager`, which only an iPhone or a cellular iPad can do, and the house's iPad is the Wi-Fi model (`iPad15,7`). Apple lets an application's advert carry only a local name and service UUIDs, so the iPhone's beacon shape is settled when the slice starts. The proof drives both devices from the Mac and answers the system's alerts through an XCUITest bundle (decided with the maintainer 2026-10-09), and the iPad joins the iPhone by beacon, payload and form. **Built (R76, 2026-10-12)** as `connetto-peer-apple`, the bridge to `CBPeripheralManager`, `NEHotspotConfiguration` and `UIDevice`, with `AppleBluetoothBackend` and `AppleHotspotBackend` in `connetto-client`, the btleplug central over CoreBluetooth, the demo's Personal Hotspot form, and `connetto-ios-proof --peer-device --every-join` with an XCUITest tapper that answers the system's alerts and switches Personal Hotspot in Settings. On 2026-10-12 the iPhone 15 Pro Max on iOS 26.6 hosted and the iPad A16 joined by the beacon, the pasted payload and the typed form, each linking over the hotspot, in one unattended run. Measured on those devices: the iPhone's hotspot is WPA2 while Maximize Compatibility is on, `NEIPv4Route.defaultRoute` carries no gateway, so the joined network's gateway is read from the routing table through `sysctl`, the iPhone's listener answers at 172.20.10.1, and iOS refuses an application's first connection to a local address with `EHOSTUNREACH` while it asks for local-network access, which decision 11's redial covers. A Personal Hotspot shows its name to devices outside the owner's account only while its pane is open, so the join asks for the network by name as a hidden one. + - 9b. **Linux.** Hosting and joining through NetworkManager, the beacon on BlueZ, and the btleplug central, proven by a native client on pippo against a phone in radio range of it, since emi has neither Wi-Fi nor Bluetooth. + - 9c. **macOS.** Joining through CoreWLAN and the btleplug central, the Mac never hosting (decision 1). + - 9d. **Windows.** Hosting through a Wi-Fi Direct advertisement in legacy mode, joining through the WLAN API, the beacon through `GattServiceProvider`, and the btleplug central. + - 9e. **The recorded home run** across every local machine, with Windows and Linux each hosting once. ### Done when diff --git a/sonar-project.properties b/sonar-project.properties index 3e620bdb..ff60eeec 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -30,6 +30,7 @@ sonar.rust.cargo.manifestPaths=Cargo.toml sonar.coverage.exclusions=**/*.sql,**/*.swift,**/*.kt,\ crates/connetto-test-harness/src/bin/**,\ crates/connetto-test-harness/src/stack.rs,\ + crates/connetto-test-harness/src/peer_devices.rs,\ crates/connetto-web/**,\ examples/**,\ crates/connetto-app-attest/src/ios.rs,\