Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 98 additions & 93 deletions wacore/src/companion_reg.rs
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
//! `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.
//! `companion_platform_id` + `companion_platform_display` emission for
//! pair-code and QR linking. Encoding only — the wire field is never
//! parsed back into this enum.

use waproto::whatsapp as wa;

/// Prefix `WAWebLinkDeviceQrcode` uses when iOS native-camera linking is on.
/// 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: 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.
/// Each variant has a fixed single-byte ASCII wire form. Web codes follow
/// `WAWebCompanionRegClientUtils.DEVICE_PLATFORM`; the Android letters
/// come from the official Android client and require server-side
/// attestation, so they are reachable only through explicit opt-in.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum CompanionWebClientType {
// Web (digit codes from WAWebCompanionRegClientUtils.DEVICE_PLATFORM).
#[default]
Unknown,
Chrome,
Edge,
Firefox,
Expand All @@ -29,8 +22,13 @@ pub enum CompanionWebClientType {
Safari,
Electron,
Uwp,
/// Catch-all WA Web emits when the browser detection returns a name
/// that doesn't match any known cohort. Also the safe default for
/// platforms with no confirmed wire byte. The proto's `UNKNOWN`
/// (`'0'`) is intentionally not represented: real browsers never
/// emit it and the server rejects it.
#[default]
OtherWebClient,
// Mobile (letter codes from the official WhatsApp Android client).
AndroidTablet,
AndroidPhone,
AndroidAmbiguous,
Expand All @@ -40,7 +38,6 @@ impl CompanionWebClientType {
/// Single-byte ASCII id placed in `<companion_platform_id>`.
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',
Expand Down Expand Up @@ -75,8 +72,7 @@ pub const fn companion_browser_name(ct: CompanionWebClientType) -> &'static str
CompanionWebClientType::Ie => "IE",
CompanionWebClientType::Opera => "Opera",
CompanionWebClientType::Safari => "Safari",
CompanionWebClientType::Unknown
| CompanionWebClientType::Electron
CompanionWebClientType::Electron
| CompanionWebClientType::Uwp
| CompanionWebClientType::OtherWebClient
| CompanionWebClientType::AndroidTablet
Expand All @@ -85,16 +81,25 @@ pub const fn companion_browser_name(ct: CompanionWebClientType) -> &'static str
}
}

/// Maps `DeviceProps::PlatformType` to a wire variant. Variants without
/// a confirmed letter fall back to `OtherWebClient` ('9'), which the
/// server still accepts.
/// Maps `DeviceProps::PlatformType` to a wire variant.
///
/// Android variants map to `Chrome`: that's what real WA Web on
/// Chrome-Android emits (`info().name === "Chrome"`) and what the server
/// accepts without attestation. The Android letters `'e'`/`'d'`/`'f'`
/// stay reachable through explicit opt-in via `PairCodeOptions::platform_id`
/// but require Android-side signing material the server validates.
///
/// Platforms without a confirmed wire byte (iOS, AR/VR, etc.) and the
/// proto's `UNKNOWN` collapse to `OtherWebClient` (`'9'`). `'0'` is
/// deliberately unreachable: WA Web only emits it when `info().name` is
/// null, which doesn't happen in a real browser, and the server rejects
/// it from any non-WA-Web client.
pub const fn companion_web_client_type_for_platform(
pt: wa::device_props::PlatformType,
) -> CompanionWebClientType {
use CompanionWebClientType as C;
use wa::device_props::PlatformType as P;
match pt {
P::Unknown => C::Unknown,
P::Chrome => C::Chrome,
P::Firefox => C::Firefox,
P::Ie => C::Ie,
Expand All @@ -103,10 +108,9 @@ 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::AndroidPhone | P::AndroidTablet | P::AndroidAmbiguous => C::Chrome,
P::Unknown
| P::Ipad
| P::Ohana
| P::Aloha
| P::Catalina
Expand All @@ -127,7 +131,7 @@ pub fn companion_web_client_type_for_props(props: &wa::DeviceProps) -> Companion
.platform_type
.and_then(|v| wa::device_props::PlatformType::try_from(v).ok())
.map(companion_web_client_type_for_platform)
.unwrap_or_default()
.unwrap_or(CompanionWebClientType::OtherWebClient)
}

/// `companion_platform_display` body. Server validates only length
Expand All @@ -153,7 +157,6 @@ mod tests {

#[test]
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');
Expand All @@ -174,7 +177,6 @@ mod tests {

#[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");
Expand All @@ -183,65 +185,56 @@ mod tests {
}

#[test]
fn default_is_unknown_zero() {
fn default_is_other_web_client_nine() {
assert_eq!(
CompanionWebClientType::default(),
CompanionWebClientType::Unknown,
);
assert_eq!(CompanionWebClientType::default().wire_byte(), b'0');
}

#[test]
fn browser_platform_types_round_trip() {
use CompanionWebClientType as C;
use wa::device_props::PlatformType as P;
assert_eq!(companion_web_client_type_for_platform(P::Chrome), C::Chrome);
assert_eq!(
companion_web_client_type_for_platform(P::Firefox),
C::Firefox
CompanionWebClientType::OtherWebClient,
);
assert_eq!(companion_web_client_type_for_platform(P::Edge), C::Edge);
assert_eq!(companion_web_client_type_for_platform(P::Safari), C::Safari);
assert_eq!(companion_web_client_type_for_platform(P::Opera), C::Opera);
assert_eq!(companion_web_client_type_for_platform(P::Ie), C::Ie);
assert_eq!(CompanionWebClientType::default().wire_byte(), b'9');
}

#[test]
fn desktop_maps_to_electron_and_uwp_preserved() {
fn browser_and_desktop_platform_types_map_to_their_variants() {
use CompanionWebClientType as C;
use wa::device_props::PlatformType as P;
assert_eq!(
companion_web_client_type_for_platform(P::Desktop),
C::Electron
);
assert_eq!(companion_web_client_type_for_platform(P::Uwp), C::Uwp);
for (pt, expected) in [
(P::Chrome, C::Chrome),
(P::Firefox, C::Firefox),
(P::Edge, C::Edge),
(P::Safari, C::Safari),
(P::Opera, C::Opera),
(P::Ie, C::Ie),
(P::Desktop, C::Electron),
(P::Uwp, C::Uwp),
] {
assert_eq!(
companion_web_client_type_for_platform(pt),
expected,
"{pt:?}"
);
}
}

/// Android values map to `Chrome` (`'1'`) — what real WA Web on
/// Chrome-Android emits and what the server accepts. The Android
/// letters stay opt-in only.
#[test]
fn android_platform_types_map_to_dedicated_letters() {
fn android_platform_types_map_to_chrome() {
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,
);
for pt in [P::AndroidPhone, P::AndroidTablet, P::AndroidAmbiguous] {
assert_eq!(
companion_web_client_type_for_platform(pt),
C::Chrome,
"{pt:?}"
);
}
}

#[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::IosPhone,
Expand All @@ -260,11 +253,31 @@ mod tests {
assert_eq!(
companion_web_client_type_for_platform(pt),
C::OtherWebClient,
"{pt:?} must fall back to OtherWebClient",
"{pt:?}",
);
}
}

/// `P::Unknown` collapses to `OtherWebClient` — the server rejects
/// `'0'` from any non-WA-Web client.
#[test]
fn proto_unknown_collapses_to_other_web_client() {
use CompanionWebClientType as C;
use wa::device_props::PlatformType as P;
assert_eq!(
companion_web_client_type_for_platform(P::Unknown),
C::OtherWebClient,
);
}

/// Explicit opt-in via the enum still renders the right wire byte.
#[test]
fn android_variants_still_emit_their_wire_bytes_when_used_directly() {
assert_eq!(CompanionWebClientType::AndroidPhone.wire_byte(), b'e');
assert_eq!(CompanionWebClientType::AndroidTablet.wire_byte(), b'd');
assert_eq!(CompanionWebClientType::AndroidAmbiguous.wire_byte(), b'f');
}

#[test]
fn for_props_reads_platform_type() {
let props = wa::DeviceProps {
Expand All @@ -278,52 +291,44 @@ mod tests {
}

#[test]
fn for_props_missing_platform_type_is_unknown() {
fn for_props_missing_platform_type_is_other_web_client() {
let props = wa::DeviceProps::default();
assert_eq!(
companion_web_client_type_for_props(&props),
CompanionWebClientType::Unknown,
CompanionWebClientType::OtherWebClient,
);
}

#[test]
fn for_props_invalid_platform_type_is_unknown() {
fn for_props_invalid_platform_type_is_other_web_client() {
let props = wa::DeviceProps {
platform_type: Some(9999),
..Default::default()
};
assert_eq!(
companion_web_client_type_for_props(&props),
CompanionWebClientType::Unknown,
CompanionWebClientType::OtherWebClient,
);
}

#[test]
fn browser_name_for_six_valid_browsers() {
assert_eq!(
companion_browser_name(CompanionWebClientType::Chrome),
"Chrome"
);
assert_eq!(companion_browser_name(CompanionWebClientType::Edge), "Edge");
assert_eq!(
companion_browser_name(CompanionWebClientType::Firefox),
"Firefox"
);
assert_eq!(companion_browser_name(CompanionWebClientType::Ie), "IE");
assert_eq!(
companion_browser_name(CompanionWebClientType::Opera),
"Opera"
);
assert_eq!(
companion_browser_name(CompanionWebClientType::Safari),
"Safari"
);
use CompanionWebClientType as C;
for (ct, name) in [
(C::Chrome, "Chrome"),
(C::Edge, "Edge"),
(C::Firefox, "Firefox"),
(C::Ie, "IE"),
(C::Opera, "Opera"),
(C::Safari, "Safari"),
] {
assert_eq!(companion_browser_name(ct), name, "{ct:?}");
}
}

#[test]
fn browser_name_for_non_browser_falls_back_to_chrome() {
for ct in [
CompanionWebClientType::Unknown,
CompanionWebClientType::Electron,
CompanionWebClientType::Uwp,
CompanionWebClientType::OtherWebClient,
Expand Down
18 changes: 9 additions & 9 deletions wacore/src/pair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,6 @@ mod tests {
fn make_qr_data_renders_each_client_type_wire_byte() {
let state = dummy_device_state();
for (ct, wire) in [
(CompanionWebClientType::Unknown, "0"),
(CompanionWebClientType::Chrome, "1"),
(CompanionWebClientType::Edge, "2"),
(CompanionWebClientType::Firefox, "3"),
Expand Down Expand Up @@ -554,12 +553,14 @@ 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, "e"),
(wa::device_props::PlatformType::AndroidTablet, "d"),
(wa::device_props::PlatformType::AndroidAmbiguous, "f"),
// Android* maps to Chrome ('1') — what real WA Web on
// Chrome-Android emits and what the server accepts.
(wa::device_props::PlatformType::AndroidPhone, "1"),
(wa::device_props::PlatformType::AndroidTablet, "1"),
(wa::device_props::PlatformType::AndroidAmbiguous, "1"),
(wa::device_props::PlatformType::IosPhone, "9"),
(wa::device_props::PlatformType::Vr, "9"),
(wa::device_props::PlatformType::Unknown, "0"),
(wa::device_props::PlatformType::Unknown, "9"),
];
let state = dummy_device_state();
for (pt, expected_wire) in cases {
Expand All @@ -574,9 +575,9 @@ mod tests {
}
}

/// Bare `DeviceProps` produces 5-field QR with trailing "0", no panic.
/// Bare `DeviceProps` produces 5-field QR with trailing "9" (catch-all).
#[test]
fn auto_derive_default_device_props_yields_unknown_zero() {
fn auto_derive_default_device_props_yields_other_web_client_nine() {
use crate::companion_reg::companion_web_client_type_for_props;
use waproto::whatsapp as wa;

Expand All @@ -585,15 +586,14 @@ mod tests {
let qr = PairUtils::make_qr_data(&state, "ref", ct);
let parts: Vec<&str> = qr.split(',').collect();
assert_eq!(parts.len(), 5);
assert_eq!(parts[4], "0");
assert_eq!(parts[4], "9");
}

/// `make_qr_data` output must always round-trip through `parse_qr_code`.
#[test]
fn round_trip_make_then_parse_for_every_client_type() {
let state = dummy_device_state();
for ct in [
CompanionWebClientType::Unknown,
CompanionWebClientType::Chrome,
CompanionWebClientType::Edge,
CompanionWebClientType::Firefox,
Expand Down
Loading
Loading