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