Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
162 changes: 115 additions & 47 deletions wacore/src/companion_reg.rs
Original file line number Diff line number Diff line change
@@ -1,43 +1,72 @@
//! 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;

/// 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: 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 `<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',
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',
}
}
Comment on lines 19 to 57
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Put the wire value on the enum itself.

This is protocol surface area, so the byte mapping needs to live on the variants, not in a separate manual match. Right now CompanionWebClientType and wire_byte() can drift independently, which is exactly the failure mode the repo rule is trying to prevent. Please move this enum to #[derive(WireEnum)] with per-variant #[wire = ...] annotations, and keep wire_byte() only as a thin helper if you still want that API.

As per coding guidelines, "Every protocol enum must use #[derive(WireEnum)]; the #[wire = "..."] or #[wire = NUM] attribute is the single source of truth for each variant's wire value; do not also derive serde::Serialize/Deserialize or add #[serde(rename_all)]".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/companion_reg.rs` around lines 19 - 57, Replace the manual
match-based wire mapping with a WireEnum-derived enum: add #[derive(WireEnum,
Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] to CompanionWebClientType and
annotate every variant with its wire value (e.g. #[wire = b'0'] on Unknown,
#[wire = b'1'] on Chrome, etc.), remove the explicit match body inside
wire_byte() and make wire_byte() a thin helper that returns the variant's wire
value via the WireEnum implementation (or remove it if redundant), and ensure
you do not derive serde::Serialize/Deserialize or add serde renames so the
#[wire = ...] attributes are the single source of truth for the protocol
mapping.

}

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",
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -99,37 +130,56 @@ pub fn companion_web_client_type_for_props(props: &wa::DeviceProps) -> Companion
.unwrap_or_default()
}

/// `<browser> (<os>)` 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
/// `<Browser> (<OS>)`, mirroring `WAWebAltDeviceLinkingIq`; Android
/// variants emit `Android (<OS>)`, 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)]
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]
Expand All @@ -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]
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 14 additions & 5 deletions wacore/src/pair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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:?}");
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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());
}
}
}
Loading
Loading