diff --git a/wacore/src/companion_reg.rs b/wacore/src/companion_reg.rs index 053993a1..9dde64aa 100644 --- a/wacore/src/companion_reg.rs +++ b/wacore/src/companion_reg.rs @@ -1,4 +1,11 @@ -//! Mirrors `WAWebCompanionRegClientUtils.DEVICE_PLATFORM`. +//! `companion_platform_id` and `companion_platform_display` emission. +//! +//! Server accepts 23 single-byte ids: digits `0..9` (WA Web) and letters +//! `a..m`. Only the 13 with a confirmed platform meaning are exposed. +//! Sources: WA Web `WAWebCompanionRegClientUtils` for the digits; the +//! official WhatsApp Android client for the mobile letters `d`, `e`, +//! `f`. Adding the rest without binary or wire confirmation risks +//! mislabelling the device on the primary side. use waproto::whatsapp as wa; @@ -6,38 +13,60 @@ use waproto::whatsapp as wa; /// Concatenate with `make_qr_data` output to get a scannable deep-link URL. pub const NATIVE_CAMERA_DEEP_LINK_PREFIX: &str = "https://wa.me/settings/linked_devices#"; -/// Encode-only: discriminants pinned to wire ints, no decode fallback. +/// Encode-only: every variant has a fixed single-byte ASCII wire form. +/// Decoding from the wire is not modelled because this crate only emits +/// the field, never receives it. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] -#[repr(i32)] pub enum CompanionWebClientType { + // Web (digit codes from WAWebCompanionRegClientUtils.DEVICE_PLATFORM). #[default] - Unknown = 0, - Chrome = 1, - Edge = 2, - Firefox = 3, - Ie = 4, - Opera = 5, - Safari = 6, - Electron = 7, - Uwp = 8, - OtherWebClient = 9, + Unknown, + Chrome, + Edge, + Firefox, + Ie, + Opera, + Safari, + Electron, + Uwp, + OtherWebClient, + // Mobile (letter codes from the official WhatsApp Android client). + AndroidTablet, + AndroidPhone, + AndroidAmbiguous, } impl CompanionWebClientType { - pub const fn code(self) -> i32 { - self as i32 + /// Single-byte ASCII id placed in ``. + pub const fn wire_byte(self) -> u8 { + match self { + Self::Unknown => b'0', + Self::Chrome => b'1', + Self::Edge => b'2', + Self::Firefox => b'3', + Self::Ie => b'4', + Self::Opera => b'5', + Self::Safari => b'6', + Self::Electron => b'7', + Self::Uwp => b'8', + Self::OtherWebClient => b'9', + Self::AndroidTablet => b'd', + Self::AndroidPhone => b'e', + Self::AndroidAmbiguous => b'f', + } } } impl std::fmt::Display for CompanionWebClientType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.code().fmt(f) + write!(f, "{}", self.wire_byte() as char) } } -/// One of the 6 browsers the server accepts in `companion_platform_display` -/// (per whatsmeow `PairPhone` doc). Non-browser variants fall back to "Chrome" -/// since WA Web emits the same when the underlying renderer is Chromium. +/// Browser label for `companion_platform_display`. Non-browser variants +/// fall back to "Chrome" because WA Web's `info().name` reports the +/// underlying Chromium renderer in those contexts. Mobile variants are +/// short-circuited by [`companion_platform_display`] before reaching here. pub const fn companion_browser_name(ct: CompanionWebClientType) -> &'static str { match ct { CompanionWebClientType::Chrome => "Chrome", @@ -49,14 +78,16 @@ pub const fn companion_browser_name(ct: CompanionWebClientType) -> &'static str CompanionWebClientType::Unknown | CompanionWebClientType::Electron | CompanionWebClientType::Uwp - | CompanionWebClientType::OtherWebClient => "Chrome", + | CompanionWebClientType::OtherWebClient + | CompanionWebClientType::AndroidTablet + | CompanionWebClientType::AndroidPhone + | CompanionWebClientType::AndroidAmbiguous => "Chrome", } } -/// Non-web platforms collapse to `OtherWebClient`, matching WA Web's -/// `info().name` fall-through. WA Web's `gkx("4112") ⇒ UWP` host-shell -/// short-circuit isn't replicated — caller sets `PlatformType::Uwp` -/// explicitly. Same for `Electron` via `Desktop`. +/// Maps `DeviceProps::PlatformType` to a wire variant. Variants without +/// a confirmed letter fall back to `OtherWebClient` ('9'), which the +/// server still accepts. pub const fn companion_web_client_type_for_platform( pt: wa::device_props::PlatformType, ) -> CompanionWebClientType { @@ -72,16 +103,16 @@ pub const fn companion_web_client_type_for_platform( P::Edge => C::Edge, P::Desktop => C::Electron, P::Uwp => C::Uwp, + P::AndroidPhone => C::AndroidPhone, + P::AndroidTablet => C::AndroidTablet, + P::AndroidAmbiguous => C::AndroidAmbiguous, P::Ipad - | P::AndroidTablet | P::Ohana | P::Aloha | P::Catalina | P::TclTv | P::IosPhone | P::IosCatalyst - | P::AndroidPhone - | P::AndroidAmbiguous | P::WearOs | P::ArWrist | P::ArDevice @@ -99,12 +130,21 @@ pub fn companion_web_client_type_for_props(props: &wa::DeviceProps) -> Companion .unwrap_or_default() } -/// ` ()` as WA Web emits. Empty OS falls back to "Linux" -/// since WA Web never sends a bare browser. +/// `companion_platform_display` body. Server validates only length +/// 1..=100; there is no browser whitelist. Web variants emit +/// ` ()`, mirroring `WAWebAltDeviceLinkingIq`; Android +/// variants emit `Android ()`, matching the official Android client. +/// Empty OS substitutes `Linux`. pub fn companion_platform_display(ct: CompanionWebClientType, os: &str) -> String { + use CompanionWebClientType as C; let os = os.trim(); let os = if os.is_empty() { "Linux" } else { os }; - format!("{} ({})", companion_browser_name(ct), os) + match ct { + C::AndroidPhone | C::AndroidTablet | C::AndroidAmbiguous => { + format!("Android ({os})") + } + _ => format!("{} ({})", companion_browser_name(ct), os), + } } #[cfg(test)] @@ -112,24 +152,34 @@ mod tests { use super::*; #[test] - fn wire_codes_match_wa_web() { - assert_eq!(CompanionWebClientType::Unknown.code(), 0); - assert_eq!(CompanionWebClientType::Chrome.code(), 1); - assert_eq!(CompanionWebClientType::Edge.code(), 2); - assert_eq!(CompanionWebClientType::Firefox.code(), 3); - assert_eq!(CompanionWebClientType::Ie.code(), 4); - assert_eq!(CompanionWebClientType::Opera.code(), 5); - assert_eq!(CompanionWebClientType::Safari.code(), 6); - assert_eq!(CompanionWebClientType::Electron.code(), 7); - assert_eq!(CompanionWebClientType::Uwp.code(), 8); - assert_eq!(CompanionWebClientType::OtherWebClient.code(), 9); + fn wire_byte_matches_wa_web() { + assert_eq!(CompanionWebClientType::Unknown.wire_byte(), b'0'); + assert_eq!(CompanionWebClientType::Chrome.wire_byte(), b'1'); + assert_eq!(CompanionWebClientType::Edge.wire_byte(), b'2'); + assert_eq!(CompanionWebClientType::Firefox.wire_byte(), b'3'); + assert_eq!(CompanionWebClientType::Ie.wire_byte(), b'4'); + assert_eq!(CompanionWebClientType::Opera.wire_byte(), b'5'); + assert_eq!(CompanionWebClientType::Safari.wire_byte(), b'6'); + assert_eq!(CompanionWebClientType::Electron.wire_byte(), b'7'); + assert_eq!(CompanionWebClientType::Uwp.wire_byte(), b'8'); + assert_eq!(CompanionWebClientType::OtherWebClient.wire_byte(), b'9'); } #[test] - fn display_renders_decimal_wire_integer() { + fn wire_byte_matches_apk_for_mobile() { + assert_eq!(CompanionWebClientType::AndroidTablet.wire_byte(), b'd'); + assert_eq!(CompanionWebClientType::AndroidPhone.wire_byte(), b'e'); + assert_eq!(CompanionWebClientType::AndroidAmbiguous.wire_byte(), b'f'); + } + + #[test] + fn display_renders_wire_byte_as_char() { assert_eq!(format!("{}", CompanionWebClientType::Unknown), "0"); assert_eq!(format!("{}", CompanionWebClientType::Chrome), "1"); assert_eq!(format!("{}", CompanionWebClientType::OtherWebClient), "9"); + assert_eq!(format!("{}", CompanionWebClientType::AndroidPhone), "e"); + assert_eq!(format!("{}", CompanionWebClientType::AndroidTablet), "d"); + assert_eq!(format!("{}", CompanionWebClientType::AndroidAmbiguous), "f"); } #[test] @@ -138,7 +188,7 @@ mod tests { CompanionWebClientType::default(), CompanionWebClientType::Unknown, ); - assert_eq!(CompanionWebClientType::default().code(), 0); + assert_eq!(CompanionWebClientType::default().wire_byte(), b'0'); } #[test] @@ -168,16 +218,34 @@ mod tests { } #[test] - fn mobile_and_xr_collapse_to_other() { + fn android_platform_types_map_to_dedicated_letters() { + use CompanionWebClientType as C; + use wa::device_props::PlatformType as P; + assert_eq!( + companion_web_client_type_for_platform(P::AndroidPhone), + C::AndroidPhone, + ); + assert_eq!( + companion_web_client_type_for_platform(P::AndroidTablet), + C::AndroidTablet, + ); + assert_eq!( + companion_web_client_type_for_platform(P::AndroidAmbiguous), + C::AndroidAmbiguous, + ); + } + + #[test] + fn unconfirmed_platform_types_collapse_to_other() { use CompanionWebClientType as C; use wa::device_props::PlatformType as P; + // No confirmed letter for these yet (would need iOS/Mac/Quest RE + // or live capture). Fallback to OtherWebClient ('9') stays + // server-valid. for pt in [ P::Ipad, - P::AndroidPhone, - P::AndroidTablet, P::IosPhone, P::IosCatalyst, - P::AndroidAmbiguous, P::WearOs, P::ArWrist, P::ArDevice, diff --git a/wacore/src/pair.rs b/wacore/src/pair.rs index 97121442..7acd66a7 100644 --- a/wacore/src/pair.rs +++ b/wacore/src/pair.rs @@ -420,7 +420,7 @@ mod tests { } #[test] - fn make_qr_data_renders_each_client_type_decimal_integer() { + fn make_qr_data_renders_each_client_type_wire_byte() { let state = dummy_device_state(); for (ct, wire) in [ (CompanionWebClientType::Unknown, "0"), @@ -433,6 +433,9 @@ mod tests { (CompanionWebClientType::Electron, "7"), (CompanionWebClientType::Uwp, "8"), (CompanionWebClientType::OtherWebClient, "9"), + (CompanionWebClientType::AndroidTablet, "d"), + (CompanionWebClientType::AndroidPhone, "e"), + (CompanionWebClientType::AndroidAmbiguous, "f"), ] { let qr = PairUtils::make_qr_data(&state, "r", ct); assert_eq!(qr.rsplit(',').next(), Some(wire), "{ct:?}"); @@ -551,7 +554,9 @@ mod tests { (wa::device_props::PlatformType::Edge, "2"), (wa::device_props::PlatformType::Desktop, "7"), (wa::device_props::PlatformType::Uwp, "8"), - (wa::device_props::PlatformType::AndroidPhone, "9"), + (wa::device_props::PlatformType::AndroidPhone, "e"), + (wa::device_props::PlatformType::AndroidTablet, "d"), + (wa::device_props::PlatformType::AndroidAmbiguous, "f"), (wa::device_props::PlatformType::IosPhone, "9"), (wa::device_props::PlatformType::Vr, "9"), (wa::device_props::PlatformType::Unknown, "0"), @@ -598,6 +603,9 @@ mod tests { CompanionWebClientType::Electron, CompanionWebClientType::Uwp, CompanionWebClientType::OtherWebClient, + CompanionWebClientType::AndroidTablet, + CompanionWebClientType::AndroidPhone, + CompanionWebClientType::AndroidAmbiguous, ] { let qr = PairUtils::make_qr_data(&state, "the-ref", ct); let (pairing_ref, noise, identity) = PairUtils::parse_qr_code(&qr) @@ -702,18 +710,19 @@ mod tests { assert_eq!(err.text, "hmac-mismatch"); } - /// QR trailing field == `code()` (parity with `companion_platform_id`). + /// QR trailing field is the wire byte of `companion_platform_id`. #[test] - fn qr_trailing_field_matches_companion_web_client_type_code() { + fn qr_trailing_field_matches_companion_web_client_type_wire_byte() { let state = dummy_device_state(); for ct in [ CompanionWebClientType::Chrome, CompanionWebClientType::OtherWebClient, CompanionWebClientType::Uwp, + CompanionWebClientType::AndroidPhone, ] { let qr = PairUtils::make_qr_data(&state, "r", ct); let trailing = qr.rsplit(',').next().unwrap(); - assert_eq!(trailing, ct.code().to_string()); + assert_eq!(trailing, &(ct.wire_byte() as char).to_string()); } } } diff --git a/wacore/src/pair_code.rs b/wacore/src/pair_code.rs index 82c87e7a..0d290a37 100644 --- a/wacore/src/pair_code.rs +++ b/wacore/src/pair_code.rs @@ -644,11 +644,10 @@ mod tests { #[test] fn derive_firefox_uses_companion_web_client_wire() { - // Regression: proto Firefox=2 vs CWCT Firefox=3. let p = props(Some("Linux"), Some(wa::device_props::PlatformType::Firefox)); let (id, display) = derive_companion_platform(&p); assert_eq!(id, CompanionWebClientType::Firefox); - assert_eq!(id.code(), 3); + assert_eq!(id.wire_byte(), b'3'); assert_eq!(display, "Firefox (Linux)"); } @@ -657,21 +656,44 @@ mod tests { let p = props(Some("Windows"), Some(wa::device_props::PlatformType::Edge)); let (id, display) = derive_companion_platform(&p); assert_eq!(id, CompanionWebClientType::Edge); - assert_eq!(id.code(), 2); + assert_eq!(id.wire_byte(), b'2'); assert_eq!(display, "Edge (Windows)"); } #[test] - fn derive_android_phone_falls_back_to_other_web_client_and_chrome() { - // Regression: previously ("16","Android (Android)"); server rejects. + fn derive_android_phone_uses_dedicated_letter_and_android_label() { let p = props( Some("Android"), Some(wa::device_props::PlatformType::AndroidPhone), ); let (id, display) = derive_companion_platform(&p); - assert_eq!(id, CompanionWebClientType::OtherWebClient); - assert_eq!(id.code(), 9); - assert_eq!(display, "Chrome (Android)"); + assert_eq!(id, CompanionWebClientType::AndroidPhone); + assert_eq!(id.wire_byte(), b'e'); + assert_eq!(display, "Android (Android)"); + } + + #[test] + fn derive_android_tablet_uses_dedicated_letter() { + let p = props( + Some("Android"), + Some(wa::device_props::PlatformType::AndroidTablet), + ); + let (id, display) = derive_companion_platform(&p); + assert_eq!(id, CompanionWebClientType::AndroidTablet); + assert_eq!(id.wire_byte(), b'd'); + assert_eq!(display, "Android (Android)"); + } + + #[test] + fn derive_android_ambiguous_uses_dedicated_letter() { + let p = props( + Some("Android"), + Some(wa::device_props::PlatformType::AndroidAmbiguous), + ); + let (id, display) = derive_companion_platform(&p); + assert_eq!(id, CompanionWebClientType::AndroidAmbiguous); + assert_eq!(id.wire_byte(), b'f'); + assert_eq!(display, "Android (Android)"); } #[test] @@ -712,11 +734,16 @@ mod tests { ); } - /// Total scan: display always ` (Linux)` for every proto variant. + /// Every proto variant produces a wire byte from the server's + /// accept-list and a display whose label is one of the known emitter + /// cohorts (`Browser` for web, `Android` for Android). #[test] - fn derive_display_always_uses_valid_browser_for_every_proto_variant() { + fn derive_display_uses_known_label_for_every_proto_variant() { use wa::device_props::PlatformType as P; - const VALID_BROWSERS: &[&str] = &["Chrome", "Edge", "Firefox", "IE", "Opera", "Safari"]; + const SERVER_ACCEPT_LIST: &[u8] = b"0123456789abcdefghijklm"; + const KNOWN_LABELS: &[&str] = &[ + "Chrome", "Edge", "Firefox", "IE", "Opera", "Safari", "Android", + ]; for pt in [ P::Unknown, P::Chrome, @@ -747,14 +774,14 @@ mod tests { let p = props(Some("Linux"), Some(pt)); let (id, display) = derive_companion_platform(&p); assert!( - (0..=9).contains(&id.code()), - "{pt:?} produced wire {} outside CompanionWebClientType range", - id.code() + SERVER_ACCEPT_LIST.contains(&id.wire_byte()), + "{pt:?} wire byte {:?} outside server accept list", + id.wire_byte() as char, ); - let browser = display.split(" (").next().unwrap(); + let label = display.split(" (").next().unwrap(); assert!( - VALID_BROWSERS.contains(&browser), - "{pt:?} produced display {display:?} with invalid browser {browser:?}" + KNOWN_LABELS.contains(&label), + "{pt:?} produced display {display:?} with unexpected label {label:?}" ); assert!( display.ends_with(" (Linux)"), @@ -968,7 +995,7 @@ mod tests { #[test] fn companion_hello_iq_shape() { - let iq = build_iq("16", "Android (Android)"); + let iq = build_iq("e", "Android (Android)"); assert_eq!(iq.tag, "iq"); let reg = iq @@ -1000,14 +1027,14 @@ mod tests { #[test] fn companion_hello_iq_android_wire_strings() { - let iq = build_iq("9", "Chrome (Android)"); + let iq = build_iq("e", "Android (Android)"); let reg = iq .get_optional_child_by_tag(&["link_code_companion_reg"]) .unwrap(); - assert_eq!(child_bytes(reg, "companion_platform_id"), b"9"); + assert_eq!(child_bytes(reg, "companion_platform_id"), b"e"); assert_eq!( child_bytes(reg, "companion_platform_display"), - b"Chrome (Android)" + b"Android (Android)" ); } @@ -1034,18 +1061,18 @@ mod tests { ..Default::default() }; let (pid, pdisp) = resolve_companion_platform(&PairCodeOptions::default(), &props); - assert_eq!(pid, CompanionWebClientType::OtherWebClient); - assert_eq!(pid.code(), 9); - assert_eq!(pdisp, "Chrome (Android)"); + assert_eq!(pid, CompanionWebClientType::AndroidPhone); + assert_eq!(pid.wire_byte(), b'e'); + assert_eq!(pdisp, "Android (Android)"); let iq = build_iq(&pid.to_string(), &pdisp); let reg = iq .get_optional_child_by_tag(&["link_code_companion_reg"]) .unwrap(); - assert_eq!(child_bytes(reg, "companion_platform_id"), b"9"); + assert_eq!(child_bytes(reg, "companion_platform_id"), b"e"); assert_eq!( child_bytes(reg, "companion_platform_display"), - b"Chrome (Android)" + b"Android (Android)" ); }