Skip to main content

frus_core/
platform.rs

1//! **Which platform the application is running on**, and which platform's conventions it
2//! should follow (milestone 614).
3//!
4//! Two questions, kept apart:
5//!
6//! - [`TargetPlatform`] is the **design language** a widget adapts to: how a list scrolls
7//!   past its end, where a bar puts its title, how a page arrives. It is read from the
8//!   theme (`Theme::platform`), which starts at [`default_target_platform`], so an
9//!   application can show iOS behaviour on an Android phone, or a test can try both.
10//! - [`Platform`] answers questions about the **machine** itself: the operating system
11//!   the binary was built for, how many processors it has. It never changes at run time
12//!   and no theme overrides it.
13//!
14//! On the web the two part company: the binary is built for `wasm32` and runs in a browser
15//! on some operating system. [`IS_WEB`] says so, and [`default_target_platform`] is the
16//! browser's operating system, which the web shell reads from the navigator at start-up.
17
18use std::sync::atomic::{AtomicU8, Ordering};
19
20/// The platforms whose conventions a widget can follow.
21///
22/// Every platform a frus application can run on has its own value, rather than borrowing
23/// another's: a platform folded into another at the start can never be given behaviour
24/// of its own without breaking every application that relied on it.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
26pub enum TargetPlatform {
27    /// Android.
28    Android,
29    /// Fuchsia.
30    Fuchsia,
31    /// iOS and iPadOS.
32    Ios,
33    /// Linux and the other free Unix desktops.
34    Linux,
35    /// macOS.
36    MacOs,
37    /// Windows.
38    Windows,
39}
40
41impl TargetPlatform {
42    /// Every platform, in declaration order.
43    pub const ALL: [TargetPlatform; 6] = [
44        TargetPlatform::Android,
45        TargetPlatform::Fuchsia,
46        TargetPlatform::Ios,
47        TargetPlatform::Linux,
48        TargetPlatform::MacOs,
49        TargetPlatform::Windows,
50    ];
51
52    /// The platform this binary was **built for**. A web build, which is built for no
53    /// operating system, reports Android here: what the browser runs on is
54    /// [`default_target_platform`]'s business.
55    pub const fn of_build() -> Self {
56        if cfg!(target_os = "ios") {
57            TargetPlatform::Ios
58        } else if cfg!(target_os = "macos") {
59            TargetPlatform::MacOs
60        } else if cfg!(target_os = "windows") {
61            TargetPlatform::Windows
62        } else if cfg!(target_os = "fuchsia") {
63            TargetPlatform::Fuchsia
64        } else if cfg!(any(
65            target_os = "linux",
66            target_os = "freebsd",
67            target_os = "openbsd",
68            target_os = "netbsd",
69            target_os = "dragonfly"
70        )) {
71            TargetPlatform::Linux
72        } else {
73            TargetPlatform::Android
74        }
75    }
76
77    /// The platform a browser runs on, from what its navigator says: `navigator.platform`,
78    /// `navigator.userAgent` and `navigator.maxTouchPoints`.
79    ///
80    /// An iPad asks for desktop pages and says it is a Mac; it is told apart by its touch
81    /// screen. Android's `platform` is a Linux, so the user agent is asked first. Anything
82    /// unrecognised is Android.
83    pub fn from_browser(platform: &str, user_agent: &str, max_touch_points: i32) -> Self {
84        let lower = platform.to_ascii_lowercase();
85        if platform.starts_with("Mac") {
86            if max_touch_points > 2 {
87                TargetPlatform::Ios
88            } else {
89                TargetPlatform::MacOs
90            }
91        } else if lower.contains("iphone") || lower.contains("ipad") || lower.contains("ipod") {
92            TargetPlatform::Ios
93        } else if user_agent.contains("Android") {
94            TargetPlatform::Android
95        } else if platform.starts_with("Linux") {
96            TargetPlatform::Linux
97        } else if platform.starts_with("Win") {
98            TargetPlatform::Windows
99        } else {
100            TargetPlatform::Android
101        }
102    }
103
104    /// iOS or macOS: the platforms that share Apple's conventions.
105    pub const fn is_apple(self) -> bool {
106        matches!(self, TargetPlatform::Ios | TargetPlatform::MacOs)
107    }
108
109    /// Android, Fuchsia or iOS: the platforms driven by touch first.
110    pub const fn is_mobile(self) -> bool {
111        matches!(
112            self,
113            TargetPlatform::Android | TargetPlatform::Fuchsia | TargetPlatform::Ios
114        )
115    }
116
117    /// Linux, macOS or Windows: the platforms driven by a mouse and a keyboard first.
118    pub const fn is_desktop(self) -> bool {
119        !self.is_mobile()
120    }
121
122    /// The platform's name, as people write it: `"Android"`, `"iOS"`, `"macOS"`…
123    pub const fn name(self) -> &'static str {
124        match self {
125            TargetPlatform::Android => "Android",
126            TargetPlatform::Fuchsia => "Fuchsia",
127            TargetPlatform::Ios => "iOS",
128            TargetPlatform::Linux => "Linux",
129            TargetPlatform::MacOs => "macOS",
130            TargetPlatform::Windows => "Windows",
131        }
132    }
133
134    const fn code(self) -> u8 {
135        match self {
136            TargetPlatform::Android => 1,
137            TargetPlatform::Fuchsia => 2,
138            TargetPlatform::Ios => 3,
139            TargetPlatform::Linux => 4,
140            TargetPlatform::MacOs => 5,
141            TargetPlatform::Windows => 6,
142        }
143    }
144
145    const fn from_code(code: u8) -> Option<Self> {
146        match code {
147            1 => Some(TargetPlatform::Android),
148            2 => Some(TargetPlatform::Fuchsia),
149            3 => Some(TargetPlatform::Ios),
150            4 => Some(TargetPlatform::Linux),
151            5 => Some(TargetPlatform::MacOs),
152            6 => Some(TargetPlatform::Windows),
153            _ => None,
154        }
155    }
156}
157
158impl std::fmt::Display for TargetPlatform {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        f.write_str(self.name())
161    }
162}
163
164/// Whether this binary runs in a **browser**. A constant: a build is for the web or not.
165pub const IS_WEB: bool = cfg!(target_arch = "wasm32");
166
167/// The override set by [`set_debug_default_target_platform_override`]; 0 for none.
168static OVERRIDE: AtomicU8 = AtomicU8::new(0);
169/// What the shell found the platform to be at start-up (the browser's operating system);
170/// 0 until then, and on every platform where the build says it all.
171static DETECTED: AtomicU8 = AtomicU8::new(0);
172
173/// The platform whose conventions an application follows **unless its theme says
174/// otherwise**: the debug override if one is set, else the browser's operating system on
175/// the web, else the platform the binary was built for.
176///
177/// Widgets do not read this directly: they read `Theme::platform`, which starts here, so
178/// that a theme can change it for a subtree or for the whole application.
179pub fn default_target_platform() -> TargetPlatform {
180    debug_default_target_platform_override()
181        .or_else(|| TargetPlatform::from_code(DETECTED.load(Ordering::Relaxed)))
182        .unwrap_or(TargetPlatform::of_build())
183}
184
185/// The override of [`default_target_platform`], if one is set.
186pub fn debug_default_target_platform_override() -> Option<TargetPlatform> {
187    TargetPlatform::from_code(OVERRIDE.load(Ordering::Relaxed))
188}
189
190/// Makes [`default_target_platform`] report `platform`, or, with `None`, what it would
191/// report on its own: for trying another platform's behaviour during development, and
192/// for tests. It is process-wide, so a test that sets it resets it before it returns.
193///
194/// # Panics
195///
196/// In a release build (without debug assertions): an application that ships pretending
197/// to be another platform should say so in its theme, where it is visible, rather than
198/// through a switch meant for development.
199pub fn set_debug_default_target_platform_override(platform: Option<TargetPlatform>) {
200    if !cfg!(debug_assertions) {
201        panic!(
202            "the default target platform can only be overridden in a debug build; \
203             set `Theme::platform` instead"
204        );
205    }
206    OVERRIDE.store(platform.map_or(0, TargetPlatform::code), Ordering::Relaxed);
207}
208
209/// Records the platform the shell detected at start-up (the browser's operating system).
210/// For the shell; an application sets `Theme::platform` instead.
211#[doc(hidden)]
212pub fn __set_detected_target_platform(platform: TargetPlatform) {
213    DETECTED.store(platform.code(), Ordering::Relaxed);
214}
215
216/// Facts about the **machine** the application runs on, fixed for the life of the
217/// process. Unlike [`TargetPlatform`], nothing overrides them.
218pub struct Platform;
219
220impl Platform {
221    /// The operating system this binary was built for, in lower case: `"android"`,
222    /// `"ios"`, `"linux"`, `"macos"`, `"windows"`… and `""` on the web, which is built for
223    /// none.
224    pub const fn operating_system() -> &'static str {
225        std::env::consts::OS
226    }
227
228    /// Built for Android.
229    pub const fn is_android() -> bool {
230        cfg!(target_os = "android")
231    }
232
233    /// Built for iOS.
234    pub const fn is_ios() -> bool {
235        cfg!(target_os = "ios")
236    }
237
238    /// Built for Linux (not Android).
239    pub const fn is_linux() -> bool {
240        cfg!(target_os = "linux")
241    }
242
243    /// Built for macOS.
244    pub const fn is_macos() -> bool {
245        cfg!(target_os = "macos")
246    }
247
248    /// Built for Windows.
249    pub const fn is_windows() -> bool {
250        cfg!(target_os = "windows")
251    }
252
253    /// Built for Fuchsia.
254    pub const fn is_fuchsia() -> bool {
255        cfg!(target_os = "fuchsia")
256    }
257
258    /// Built for the web ([`IS_WEB`]).
259    pub const fn is_web() -> bool {
260        IS_WEB
261    }
262
263    /// How many threads the machine can run at once; 1 where it cannot say (a browser).
264    pub fn number_of_processors() -> usize {
265        std::thread::available_parallelism().map_or(1, |n| n.get())
266    }
267
268    /// The separator between a path's components: `\` on Windows, `/` elsewhere.
269    pub const fn path_separator() -> char {
270        std::path::MAIN_SEPARATOR
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    /// **The build decides, natively**: this binary reports the platform it was built for.
279    #[test]
280    fn the_default_is_the_platform_built_for() {
281        let expected = if cfg!(target_os = "windows") {
282            TargetPlatform::Windows
283        } else if cfg!(target_os = "macos") {
284            TargetPlatform::MacOs
285        } else if cfg!(target_os = "linux") {
286            TargetPlatform::Linux
287        } else {
288            TargetPlatform::of_build()
289        };
290        assert_eq!(TargetPlatform::of_build(), expected);
291        assert_eq!(Platform::operating_system(), std::env::consts::OS);
292        assert_eq!(Platform::is_web(), IS_WEB);
293    }
294
295    /// **The override wins, and goes away**: set, it is what every reader is told; reset,
296    /// they are told the truth again. The only test that touches the process-wide switch.
297    #[test]
298    fn the_debug_override_wins_until_it_is_cleared() {
299        let native = default_target_platform();
300        for platform in TargetPlatform::ALL {
301            set_debug_default_target_platform_override(Some(platform));
302            assert_eq!(default_target_platform(), platform);
303            assert_eq!(debug_default_target_platform_override(), Some(platform));
304        }
305        set_debug_default_target_platform_override(None);
306        assert_eq!(default_target_platform(), native);
307        assert_eq!(debug_default_target_platform_override(), None);
308    }
309
310    /// **A browser is told apart by its navigator**: what real browsers report.
311    #[test]
312    fn a_browser_reports_the_system_it_runs_on() {
313        let cases = [
314            // Chrome on Windows.
315            (
316                "Win32",
317                "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
318                0,
319                TargetPlatform::Windows,
320            ),
321            // Safari on a Mac.
322            (
323                "MacIntel",
324                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
325                0,
326                TargetPlatform::MacOs,
327            ),
328            // An iPad asking for the desktop page: a "Mac" with a touch screen.
329            (
330                "MacIntel",
331                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
332                5,
333                TargetPlatform::Ios,
334            ),
335            // An iPhone.
336            (
337                "iPhone",
338                "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)",
339                5,
340                TargetPlatform::Ios,
341            ),
342            // Chrome on Android, whose platform is a Linux.
343            (
344                "Linux armv8l",
345                "Mozilla/5.0 (Linux; Android 14; Pixel 8)",
346                5,
347                TargetPlatform::Android,
348            ),
349            // Firefox on a Linux desktop.
350            (
351                "Linux x86_64",
352                "Mozilla/5.0 (X11; Linux x86_64; rv:120.0)",
353                0,
354                TargetPlatform::Linux,
355            ),
356            // Something nobody recognises.
357            ("", "", 0, TargetPlatform::Android),
358        ];
359        for (platform, agent, touch, expected) in cases {
360            assert_eq!(
361                TargetPlatform::from_browser(platform, agent, touch),
362                expected,
363                "{platform:?} / {agent:?} / {touch}"
364            );
365        }
366    }
367
368    /// **The families**: Apple's two, touch first, mouse first, and each named as people
369    /// write it.
370    #[test]
371    fn platforms_fall_into_families() {
372        let apple: Vec<_> = TargetPlatform::ALL
373            .into_iter()
374            .filter(|p| p.is_apple())
375            .collect();
376        assert_eq!(apple, [TargetPlatform::Ios, TargetPlatform::MacOs]);
377        let mobile: Vec<_> = TargetPlatform::ALL
378            .into_iter()
379            .filter(|p| p.is_mobile())
380            .collect();
381        assert_eq!(
382            mobile,
383            [
384                TargetPlatform::Android,
385                TargetPlatform::Fuchsia,
386                TargetPlatform::Ios
387            ]
388        );
389        for platform in TargetPlatform::ALL {
390            assert_ne!(platform.is_mobile(), platform.is_desktop());
391            assert_eq!(TargetPlatform::from_code(platform.code()), Some(platform));
392        }
393        assert_eq!(TargetPlatform::from_code(0), None);
394        assert_eq!(TargetPlatform::Ios.to_string(), "iOS");
395        assert_eq!(TargetPlatform::MacOs.name(), "macOS");
396    }
397}