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)]
334#[path = "tests/launch_args_tests.rs"]
335mod tests;