Skip to main content

cranpose_services/
launch_args.rs

1//! Launch arguments — the typed parameters the app was started with.
2//!
3//! This is the Cranpose equivalent of reading `intent.extras` in a Jetpack
4//! Compose activity. A Cranpose app on Android is a `NativeActivity`, so it
5//! sees neither the environment of the shell that ran `am start` nor, until
6//! now, the launching `Intent`; debug and instrumentation flags read through
7//! `std::env::var` silently return nothing on device. [`launch_args`] gives
8//! the same values back, typed, on every platform.
9//!
10//! Where the values come from:
11//!
12//! * **Android** — the extras of the launching `Intent`, pushed in by the
13//!   `cranpose::android` backend (`adb shell am start ... --ez flag true`).
14//!   `onNewIntent` replaces the snapshot, exactly as `setIntent` replaces what
15//!   `getIntent().getExtras()` returns for a Compose activity.
16//! * **Desktop and iOS** — the process command line (the built-in default
17//!   below). Command line rather than environment because argv *is* the launch
18//!   payload: it is per-launch, it is not inherited by child processes, and it
19//!   is what the platform tooling already passes — `xcrun simctl launch`,
20//!   `XCUIApplication().launchArguments`, and a plain shell invocation all set
21//!   argv, while an exported environment variable leaks into every later
22//!   process in that session and cannot be replaced on relaunch.
23//! * **Web** — nothing by default; a shell may install the query string.
24//!
25//! Values keep the type the platform delivered (`--ez`/`--ei`/`--el`/`--ef`/
26//! `--es` on Android), and text values are parsed on demand, so an argument
27//! written as text on the command line still reads back as a number.
28//!
29//! ```no_run
30//! use cranpose_services::launch_args;
31//!
32//! let args = launch_args();
33//! if args.is_debuggable() && args.boolean("ob_debug").unwrap_or(false) {
34//!     let level = args.int("ob_level").unwrap_or(0);
35//!     let seed = args.long("ob_seed").unwrap_or(0);
36//!     let time_scale = args.float("ob_time_scale").unwrap_or(1.0);
37//!     let screen = args.string("ob_screen").unwrap_or("");
38//!     let _ = (level, seed, time_scale, screen);
39//! }
40//! ```
41
42use std::{cell::RefCell, rc::Rc};
43
44use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOfWithPolicy};
45use cranpose_macros::composable;
46
47/// One launch-argument value, in the type the platform delivered it.
48///
49/// Android extras arrive already typed. The command-line and query-string
50/// backends have no type information, so they deliver [`LaunchArgValue::Text`]
51/// and let the typed accessors parse it.
52#[derive(Clone, Debug, PartialEq)]
53pub enum LaunchArgValue {
54    /// `am start --ez name true`, or a bare `--name` on the command line.
55    Bool(bool),
56    /// `am start --ei name 3`.
57    Int(i32),
58    /// `am start --el name 90000000000`.
59    Long(i64),
60    /// `am start --ef name 0.5` (Android `double` extras are narrowed here).
61    Float(f32),
62    /// `am start --es name lobby`, or `--name=lobby` on the command line.
63    Text(String),
64}
65
66/// The launch arguments the app was started with.
67///
68/// An immutable snapshot: reading it never allocates, so composables may query
69/// it per frame. The platform replaces the whole snapshot when the launch
70/// parameters change (Android `onNewIntent`); it never mutates one in place.
71#[derive(Clone, Debug, Default, PartialEq)]
72pub struct LaunchArgs {
73    entries: Vec<(Box<str>, LaunchArgValue)>,
74    debuggable: bool,
75}
76
77/// Shared handle to a [`LaunchArgs`] snapshot.
78pub type LaunchArgsRef = Rc<LaunchArgs>;
79
80impl LaunchArgs {
81    /// Builds a snapshot from named values.
82    ///
83    /// Platform backends call this; apps read [`launch_args`] instead. A
84    /// repeated name keeps the first value, matching a `Bundle`, where the
85    /// later `putExtra` of the same key is what the caller has to avoid.
86    pub fn new(
87        entries: impl IntoIterator<Item = (String, LaunchArgValue)>,
88        debuggable: bool,
89    ) -> Self {
90        let mut collected: Vec<(Box<str>, LaunchArgValue)> = Vec::new();
91        for (name, value) in entries {
92            if name.is_empty() || collected.iter().any(|(known, _)| **known == *name) {
93                continue;
94            }
95            collected.push((name.into_boxed_str(), value));
96        }
97        Self {
98            entries: collected,
99            debuggable,
100        }
101    }
102
103    /// Whether the OS considers this build debuggable — Android's
104    /// `ApplicationInfo.FLAG_DEBUGGABLE`, `cfg!(debug_assertions)` elsewhere.
105    ///
106    /// Gate debug and instrumentation options on this. It is the only check
107    /// that stays correct in a shipped build: a release APK reports `false`
108    /// even when someone passes the extras, so the options cannot be turned on
109    /// from the outside.
110    pub fn is_debuggable(&self) -> bool {
111        self.debuggable
112    }
113
114    /// Whether an argument with this name was supplied, whatever its type.
115    ///
116    /// The reference pattern is a single presence flag (`ob_debug`) that
117    /// switches a whole block of options on.
118    pub fn contains(&self, name: &str) -> bool {
119        self.value(name).is_some()
120    }
121
122    /// The names supplied, in the order the platform reported them.
123    pub fn names(&self) -> impl Iterator<Item = &str> {
124        self.entries.iter().map(|(name, _)| &**name)
125    }
126
127    /// The number of arguments supplied.
128    pub fn len(&self) -> usize {
129        self.entries.len()
130    }
131
132    /// Whether the app was launched without any arguments.
133    pub fn is_empty(&self) -> bool {
134        self.entries.is_empty()
135    }
136
137    /// The raw value, in the type the platform delivered.
138    pub fn value(&self, name: &str) -> Option<&LaunchArgValue> {
139        self.entries
140            .iter()
141            .find(|(known, _)| &**known == name)
142            .map(|(_, value)| value)
143    }
144
145    /// Reads a boolean argument.
146    ///
147    /// Accepts a `Bool` value, or text spelled `true`/`false`, `1`/`0`,
148    /// `yes`/`no`, `on`/`off` in any case. A number is *not* coerced: an
149    /// `--ei flag 1` that was meant to be `--ez flag true` reads as `None`
150    /// rather than silently enabling something.
151    pub fn boolean(&self, name: &str) -> Option<bool> {
152        match self.value(name)? {
153            LaunchArgValue::Bool(value) => Some(*value),
154            LaunchArgValue::Text(text) => parse_boolean(text),
155            _ => None,
156        }
157    }
158
159    /// Reads a 32-bit integer argument.
160    ///
161    /// Accepts an `Int`, a `Long` that fits, or text.
162    pub fn int(&self, name: &str) -> Option<i32> {
163        match self.value(name)? {
164            LaunchArgValue::Int(value) => Some(*value),
165            LaunchArgValue::Long(value) => i32::try_from(*value).ok(),
166            LaunchArgValue::Text(text) => text.trim().parse().ok(),
167            _ => None,
168        }
169    }
170
171    /// Reads a 64-bit integer argument.
172    ///
173    /// Accepts a `Long`, an `Int`, or text. Seeds are the usual case, and a
174    /// seed written as `--ei` still reads back here.
175    pub fn long(&self, name: &str) -> Option<i64> {
176        match self.value(name)? {
177            LaunchArgValue::Long(value) => Some(*value),
178            LaunchArgValue::Int(value) => Some(i64::from(*value)),
179            LaunchArgValue::Text(text) => text.trim().parse().ok(),
180            _ => None,
181        }
182    }
183
184    /// Reads a float argument.
185    ///
186    /// Accepts a `Float`, an integer widened to `f32`, or text.
187    pub fn float(&self, name: &str) -> Option<f32> {
188        match self.value(name)? {
189            LaunchArgValue::Float(value) => Some(*value),
190            LaunchArgValue::Int(value) => Some(*value as f32),
191            LaunchArgValue::Long(value) => Some(*value as f32),
192            LaunchArgValue::Text(text) => text.trim().parse().ok(),
193            _ => None,
194        }
195    }
196
197    /// Reads a text argument.
198    ///
199    /// Only a value that was delivered as text answers here, matching
200    /// `Bundle.getString`, which returns `null` for an `int` extra. Numbers are
201    /// not formatted back into strings.
202    pub fn string(&self, name: &str) -> Option<&str> {
203        match self.value(name)? {
204            LaunchArgValue::Text(text) => Some(text),
205            _ => None,
206        }
207    }
208}
209
210fn parse_boolean(text: &str) -> Option<bool> {
211    match text.trim().to_ascii_lowercase().as_str() {
212        "true" | "1" | "yes" | "on" => Some(true),
213        "false" | "0" | "no" | "off" => Some(false),
214        _ => None,
215    }
216}
217
218thread_local! {
219    static PLATFORM_LAUNCH_ARGS: RefCell<Option<LaunchArgsRef>> = const { RefCell::new(None) };
220    static DEFAULT_LAUNCH_ARGS: RefCell<Option<LaunchArgsRef>> = const { RefCell::new(None) };
221}
222
223/// Installs the launch arguments reported by the platform, replacing any
224/// previous snapshot.
225///
226/// Android calls this at startup with the launching intent's extras, and again
227/// from `onNewIntent`. A backend that replaces the snapshot after startup must
228/// also force a root render, because a plain shared cell is not reactive.
229pub fn set_platform_launch_args(args: LaunchArgsRef) {
230    PLATFORM_LAUNCH_ARGS.with(|cell| *cell.borrow_mut() = Some(args));
231}
232
233/// Removes any platform-reported launch arguments (tests and teardown).
234pub fn clear_platform_launch_args() {
235    PLATFORM_LAUNCH_ARGS.with(|cell| *cell.borrow_mut() = None);
236}
237
238/// The launch arguments this app was started with.
239///
240/// The platform snapshot if one was installed, otherwise the command line.
241pub fn launch_args() -> LaunchArgsRef {
242    if let Some(args) = PLATFORM_LAUNCH_ARGS.with(|cell| cell.borrow().clone()) {
243        return args;
244    }
245    DEFAULT_LAUNCH_ARGS.with(|cell| {
246        let mut cached = cell.borrow_mut();
247        cached
248            .get_or_insert_with(|| Rc::new(default_launch_args()))
249            .clone()
250    })
251}
252
253/// Whether the OS considers this build debuggable — see
254/// [`LaunchArgs::is_debuggable`].
255pub fn is_debuggable() -> bool {
256    launch_args().is_debuggable()
257}
258
259fn default_launch_args() -> LaunchArgs {
260    #[cfg(not(target_arch = "wasm32"))]
261    {
262        launch_args_from_command_line(std::env::args().skip(1), cfg!(debug_assertions))
263    }
264    #[cfg(target_arch = "wasm32")]
265    {
266        LaunchArgs::new(std::iter::empty(), cfg!(debug_assertions))
267    }
268}
269
270/// Parses `--name=value` (text) and bare `--name` (true) out of a command line.
271///
272/// A lone `--` ends parsing, and everything that is not an option is ignored,
273/// so an app that also takes positional arguments keeps them to itself. This is
274/// deliberately the smallest convention that round-trips the Android extras:
275/// `--ez f true` is `--f` or `--f=true`, `--ei n 3` is `--n=3`.
276pub fn launch_args_from_command_line(
277    tokens: impl IntoIterator<Item = String>,
278    debuggable: bool,
279) -> LaunchArgs {
280    let mut entries = Vec::new();
281    for token in tokens {
282        if token == "--" {
283            break;
284        }
285        let Some(option) = token.strip_prefix("--") else {
286            continue;
287        };
288        match option.split_once('=') {
289            Some((name, value)) => {
290                entries.push((name.to_string(), LaunchArgValue::Text(value.to_string())))
291            }
292            None => entries.push((option.to_string(), LaunchArgValue::Bool(true))),
293        }
294    }
295    LaunchArgs::new(entries, debuggable)
296}
297
298/// The composition local carrying the launch arguments.
299///
300/// Compares by pointer: a snapshot is replaced wholesale, never edited, so
301/// identity is the change signal and no deep comparison is needed per read.
302pub fn local_launch_args() -> CompositionLocal<LaunchArgsRef> {
303    thread_local! {
304        static LOCAL_LAUNCH_ARGS: RefCell<Option<CompositionLocal<LaunchArgsRef>>> = const { RefCell::new(None) };
305    }
306
307    LOCAL_LAUNCH_ARGS.with(|cell| {
308        let mut local = cell.borrow_mut();
309        local
310            .get_or_insert_with(|| compositionLocalOfWithPolicy(launch_args, Rc::ptr_eq))
311            .clone()
312    })
313}
314
315/// Provides launch arguments to `content`.
316///
317/// The platform drivers wrap the app root in this so a mid-session replacement
318/// (Android `onNewIntent`) is observed; tests use it to stand in for a launch.
319#[allow(non_snake_case)]
320#[composable]
321pub fn ProvideLaunchArgs(args: LaunchArgsRef, content: impl FnOnce()) {
322    let local = local_launch_args();
323    CompositionLocalProvider(vec![local.provides(args)], move || {
324        content();
325    });
326}
327
328/// Whether the OS considers this build debuggable, read from composition.
329#[allow(non_snake_case)]
330#[composable]
331pub fn isDebuggable() -> bool {
332    local_launch_args().current().is_debuggable()
333}
334
335#[cfg(test)]
336mod tests {
337    use std::cell::RefCell as StdRefCell;
338
339    use super::*;
340    use crate::run_test_composition;
341
342    fn args(entries: &[(&str, LaunchArgValue)]) -> LaunchArgs {
343        LaunchArgs::new(
344            entries
345                .iter()
346                .map(|(name, value)| ((*name).to_string(), value.clone())),
347            false,
348        )
349    }
350
351    fn command_line(tokens: &[&str]) -> LaunchArgs {
352        launch_args_from_command_line(tokens.iter().map(|token| (*token).to_string()), false)
353    }
354
355    #[test]
356    fn typed_extras_read_back_in_the_type_they_arrived_in() {
357        let args = args(&[
358            ("ob_autoplay", LaunchArgValue::Bool(true)),
359            ("ob_level", LaunchArgValue::Int(7)),
360            ("ob_seed", LaunchArgValue::Long(9_000_000_000)),
361            ("ob_time_scale", LaunchArgValue::Float(0.5)),
362            ("ob_screen", LaunchArgValue::Text("lobby".to_string())),
363        ]);
364
365        assert_eq!(args.boolean("ob_autoplay"), Some(true));
366        assert_eq!(args.int("ob_level"), Some(7));
367        assert_eq!(args.long("ob_seed"), Some(9_000_000_000));
368        assert_eq!(args.float("ob_time_scale"), Some(0.5));
369        assert_eq!(args.string("ob_screen"), Some("lobby"));
370    }
371
372    #[test]
373    fn a_missing_argument_reads_as_none_for_every_type() {
374        let args = args(&[]);
375
376        assert_eq!(args.boolean("absent"), None);
377        assert_eq!(args.int("absent"), None);
378        assert_eq!(args.long("absent"), None);
379        assert_eq!(args.float("absent"), None);
380        assert_eq!(args.string("absent"), None);
381        assert!(!args.contains("absent"));
382        assert!(args.is_empty());
383    }
384
385    #[test]
386    fn text_arguments_parse_into_the_requested_number_type() {
387        let args = args(&[
388            ("level", LaunchArgValue::Text("7".to_string())),
389            ("seed", LaunchArgValue::Text("9000000000".to_string())),
390            ("scale", LaunchArgValue::Text("0.25".to_string())),
391            ("flag", LaunchArgValue::Text("ON".to_string())),
392        ]);
393
394        assert_eq!(args.int("level"), Some(7));
395        assert_eq!(args.long("seed"), Some(9_000_000_000));
396        assert_eq!(args.float("scale"), Some(0.25));
397        assert_eq!(args.boolean("flag"), Some(true));
398        assert_eq!(args.int("seed"), None, "a long that does not fit an i32");
399        assert_eq!(args.boolean("level"), None, "numbers are not truthy");
400    }
401
402    #[test]
403    fn integer_arguments_widen_but_do_not_become_text() {
404        let args = args(&[("level", LaunchArgValue::Int(7))]);
405
406        assert_eq!(args.long("level"), Some(7));
407        assert_eq!(args.float("level"), Some(7.0));
408        assert_eq!(args.string("level"), None);
409    }
410
411    #[test]
412    fn the_command_line_maps_flags_and_assignments_to_arguments() {
413        let args = command_line(&[
414            "--ob_debug",
415            "--ob_level=7",
416            "positional",
417            "--ob_screen=lobby",
418        ]);
419
420        assert_eq!(args.boolean("ob_debug"), Some(true));
421        assert_eq!(args.int("ob_level"), Some(7));
422        assert_eq!(args.string("ob_screen"), Some("lobby"));
423        assert_eq!(
424            args.len(),
425            3,
426            "positional arguments are not launch arguments"
427        );
428    }
429
430    #[test]
431    fn the_command_line_stops_at_a_bare_double_dash() {
432        let args = command_line(&["--before", "--", "--after"]);
433
434        assert!(args.contains("before"));
435        assert!(!args.contains("after"));
436    }
437
438    #[test]
439    fn the_first_value_wins_when_a_name_repeats() {
440        let args = args(&[
441            ("level", LaunchArgValue::Int(1)),
442            ("level", LaunchArgValue::Int(2)),
443        ]);
444
445        assert_eq!(args.int("level"), Some(1));
446        assert_eq!(args.len(), 1);
447    }
448
449    #[test]
450    fn the_installed_platform_snapshot_takes_precedence() {
451        clear_platform_launch_args();
452        set_platform_launch_args(Rc::new(args(&[(
453            "ob_autoplay",
454            LaunchArgValue::Bool(true),
455        )])));
456
457        assert_eq!(launch_args().boolean("ob_autoplay"), Some(true));
458
459        clear_platform_launch_args();
460        assert_eq!(launch_args().boolean("ob_autoplay"), None);
461    }
462
463    #[test]
464    fn debuggable_is_reported_by_the_snapshot() {
465        clear_platform_launch_args();
466        set_platform_launch_args(Rc::new(LaunchArgs::new(std::iter::empty(), true)));
467        assert!(is_debuggable());
468
469        set_platform_launch_args(Rc::new(LaunchArgs::new(std::iter::empty(), false)));
470        assert!(!is_debuggable());
471        clear_platform_launch_args();
472    }
473
474    #[test]
475    fn provide_launch_args_reaches_composition() {
476        let captured = Rc::new(StdRefCell::new(None));
477
478        {
479            let captured = Rc::clone(&captured);
480            run_test_composition(move || {
481                let captured = Rc::clone(&captured);
482                let provided = Rc::new(LaunchArgs::new(
483                    [("ob_level".to_string(), LaunchArgValue::Int(3))],
484                    true,
485                ));
486                ProvideLaunchArgs(provided, move || {
487                    *captured.borrow_mut() = Some((
488                        local_launch_args().current().int("ob_level"),
489                        isDebuggable(),
490                    ));
491                });
492            });
493        }
494
495        assert_eq!(*captured.borrow(), Some((Some(3), true)));
496    }
497}