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#[composable]
320pub fn ProvideLaunchArgs(args: LaunchArgsRef, content: impl FnOnce()) {
321    let local = local_launch_args();
322    CompositionLocalProvider(vec![local.provides(args)], move || {
323        content();
324    });
325}
326
327/// Whether the OS considers this build debuggable, read from composition.
328#[composable]
329pub fn isDebuggable() -> bool {
330    local_launch_args().current().is_debuggable()
331}
332
333#[cfg(test)]
334mod tests {
335    use std::cell::RefCell as StdRefCell;
336
337    use super::*;
338    use crate::run_test_composition;
339
340    fn args(entries: &[(&str, LaunchArgValue)]) -> LaunchArgs {
341        LaunchArgs::new(
342            entries
343                .iter()
344                .map(|(name, value)| ((*name).to_string(), value.clone())),
345            false,
346        )
347    }
348
349    fn command_line(tokens: &[&str]) -> LaunchArgs {
350        launch_args_from_command_line(tokens.iter().map(|token| (*token).to_string()), false)
351    }
352
353    #[test]
354    fn typed_extras_read_back_in_the_type_they_arrived_in() {
355        let args = args(&[
356            ("ob_autoplay", LaunchArgValue::Bool(true)),
357            ("ob_level", LaunchArgValue::Int(7)),
358            ("ob_seed", LaunchArgValue::Long(9_000_000_000)),
359            ("ob_time_scale", LaunchArgValue::Float(0.5)),
360            ("ob_screen", LaunchArgValue::Text("lobby".to_string())),
361        ]);
362
363        assert_eq!(args.boolean("ob_autoplay"), Some(true));
364        assert_eq!(args.int("ob_level"), Some(7));
365        assert_eq!(args.long("ob_seed"), Some(9_000_000_000));
366        assert_eq!(args.float("ob_time_scale"), Some(0.5));
367        assert_eq!(args.string("ob_screen"), Some("lobby"));
368    }
369
370    #[test]
371    fn a_missing_argument_reads_as_none_for_every_type() {
372        let args = args(&[]);
373
374        assert_eq!(args.boolean("absent"), None);
375        assert_eq!(args.int("absent"), None);
376        assert_eq!(args.long("absent"), None);
377        assert_eq!(args.float("absent"), None);
378        assert_eq!(args.string("absent"), None);
379        assert!(!args.contains("absent"));
380        assert!(args.is_empty());
381    }
382
383    #[test]
384    fn text_arguments_parse_into_the_requested_number_type() {
385        let args = args(&[
386            ("level", LaunchArgValue::Text("7".to_string())),
387            ("seed", LaunchArgValue::Text("9000000000".to_string())),
388            ("scale", LaunchArgValue::Text("0.25".to_string())),
389            ("flag", LaunchArgValue::Text("ON".to_string())),
390        ]);
391
392        assert_eq!(args.int("level"), Some(7));
393        assert_eq!(args.long("seed"), Some(9_000_000_000));
394        assert_eq!(args.float("scale"), Some(0.25));
395        assert_eq!(args.boolean("flag"), Some(true));
396        assert_eq!(args.int("seed"), None, "a long that does not fit an i32");
397        assert_eq!(args.boolean("level"), None, "numbers are not truthy");
398    }
399
400    #[test]
401    fn integer_arguments_widen_but_do_not_become_text() {
402        let args = args(&[("level", LaunchArgValue::Int(7))]);
403
404        assert_eq!(args.long("level"), Some(7));
405        assert_eq!(args.float("level"), Some(7.0));
406        assert_eq!(args.string("level"), None);
407    }
408
409    #[test]
410    fn the_command_line_maps_flags_and_assignments_to_arguments() {
411        let args = command_line(&[
412            "--ob_debug",
413            "--ob_level=7",
414            "positional",
415            "--ob_screen=lobby",
416        ]);
417
418        assert_eq!(args.boolean("ob_debug"), Some(true));
419        assert_eq!(args.int("ob_level"), Some(7));
420        assert_eq!(args.string("ob_screen"), Some("lobby"));
421        assert_eq!(
422            args.len(),
423            3,
424            "positional arguments are not launch arguments"
425        );
426    }
427
428    #[test]
429    fn the_command_line_stops_at_a_bare_double_dash() {
430        let args = command_line(&["--before", "--", "--after"]);
431
432        assert!(args.contains("before"));
433        assert!(!args.contains("after"));
434    }
435
436    #[test]
437    fn the_first_value_wins_when_a_name_repeats() {
438        let args = args(&[
439            ("level", LaunchArgValue::Int(1)),
440            ("level", LaunchArgValue::Int(2)),
441        ]);
442
443        assert_eq!(args.int("level"), Some(1));
444        assert_eq!(args.len(), 1);
445    }
446
447    #[test]
448    fn the_installed_platform_snapshot_takes_precedence() {
449        clear_platform_launch_args();
450        set_platform_launch_args(Rc::new(args(&[(
451            "ob_autoplay",
452            LaunchArgValue::Bool(true),
453        )])));
454
455        assert_eq!(launch_args().boolean("ob_autoplay"), Some(true));
456
457        clear_platform_launch_args();
458        assert_eq!(launch_args().boolean("ob_autoplay"), None);
459    }
460
461    #[test]
462    fn debuggable_is_reported_by_the_snapshot() {
463        clear_platform_launch_args();
464        set_platform_launch_args(Rc::new(LaunchArgs::new(std::iter::empty(), true)));
465        assert!(is_debuggable());
466
467        set_platform_launch_args(Rc::new(LaunchArgs::new(std::iter::empty(), false)));
468        assert!(!is_debuggable());
469        clear_platform_launch_args();
470    }
471
472    #[test]
473    fn provide_launch_args_reaches_composition() {
474        let captured = Rc::new(StdRefCell::new(None));
475
476        {
477            let captured = Rc::clone(&captured);
478            run_test_composition(move || {
479                let captured = Rc::clone(&captured);
480                let provided = Rc::new(LaunchArgs::new(
481                    [("ob_level".to_string(), LaunchArgValue::Int(3))],
482                    true,
483                ));
484                ProvideLaunchArgs(provided, move || {
485                    *captured.borrow_mut() = Some((
486                        local_launch_args().current().int("ob_level"),
487                        isDebuggable(),
488                    ));
489                });
490            });
491        }
492
493        assert_eq!(*captured.borrow(), Some((Some(3), true)));
494    }
495}