browser_control/cli/
type_cmd.rs1use std::io::Read;
25use std::time::Duration;
26
27use anyhow::{bail, Result};
28
29use crate::cli::env_resolver::Source;
30use crate::cli::route;
31use crate::cli::trace::CommandTrace;
32use crate::session::backend::open_backend;
33use crate::session::with_scratch_recovery;
34
35const TYPE_TIMEOUT: Duration = Duration::from_secs(30);
36
37pub async fn run(
38 browser: Option<String>,
39 text: Option<String>,
40 stdin: bool,
41 submit: bool,
42 press_sequentially: bool,
43 target: Option<String>,
44) -> Result<()> {
45 let mut trace = CommandTrace::new("type");
46 let value = match (text, stdin) {
49 (Some(_), true) => bail!("`--text` and `--stdin` are mutually exclusive; pass one"),
50 (None, false) => bail!("one of `--text` or `--stdin` is required"),
51 (Some(t), false) => t,
52 (None, true) => read_stdin()?,
53 };
54 let result = run_inner(
55 browser,
56 &value,
57 submit,
58 press_sequentially,
59 target,
60 &mut trace,
61 )
62 .await;
63 trace.finish(result)?;
64 println!("typed {} characters", value.chars().count());
66 Ok(())
67}
68
69fn read_stdin() -> Result<String> {
76 let mut buf = String::new();
77 std::io::stdin()
78 .read_to_string(&mut buf)
79 .map_err(|e| anyhow::anyhow!("reading stdin: {e}"))?;
80 let value = buf.strip_suffix('\n').unwrap_or(&buf);
82 let value = value.strip_suffix('\r').unwrap_or(value);
83 if value.trim().is_empty() {
84 bail!("stdin was empty; the resolver produced no value (a mis-scoped token?)");
85 }
86 if value.contains('\n') {
87 bail!(
88 "stdin held {} lines; a resolver should print exactly one secret \
89 (a warning banner on stdout?)",
90 value.lines().count()
91 );
92 }
93 Ok(value.to_string())
94}
95
96async fn run_inner(
97 browser: Option<String>,
98 value: &str,
99 submit: bool,
100 press_sequentially: bool,
101 target: Option<String>,
102 trace: &mut CommandTrace,
103) -> Result<()> {
104 let r = route::preamble(browser, target.as_deref(), trace).await?;
105 let resolved = &r.resolved;
106
107 match (r.tab_name.clone(), target) {
108 (Some(name), None) => {
110 trace.route("named-tab").tab_name(&name);
111 let value = value.to_string();
112 route::run_named_tab(
113 &r,
114 &name,
115 "named tabs (`<browser>/<name>`) require a registered browser; \
116 external endpoints can't carry tab names",
117 move |b, target_id| {
118 let value = value.clone();
119 async move {
120 b.type_into_focused(
121 &target_id,
122 &value,
123 press_sequentially,
124 submit,
125 TYPE_TIMEOUT,
126 )
127 .await
128 }
129 },
130 )
131 .await
132 }
133 (None, None) => {
135 let browser_name = match &resolved.source {
136 Source::Registered { name } => name.clone(),
137 Source::External => bail!(
138 "`type` needs a registered browser or an explicit `--target`; \
139 external endpoints have no tab to focus"
140 ),
141 };
142 trace.route("scratch");
143 let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
144 let value = value.to_string();
145 with_scratch_recovery(&backend, &r.registry, &browser_name, move |b, target_id| {
146 let value = value.clone();
147 async move {
148 b.type_into_focused(
149 &target_id,
150 &value,
151 press_sequentially,
152 submit,
153 TYPE_TIMEOUT,
154 )
155 .await
156 }
157 })
158 .await
159 }
160 (None, Some(regex)) => {
165 trace.route("target-regex");
166 let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
167 let target_id = target_matching(&backend, ®ex).await?;
168 let out = backend
169 .type_into_focused(&target_id, value, press_sequentially, submit, TYPE_TIMEOUT)
170 .await;
171 backend.release().await;
172 out
173 }
174 _ => unreachable!("mutual exclusion checked in preamble"),
175 }
176}
177
178pub(crate) async fn target_matching(
183 backend: &crate::session::backend::TabBackend,
184 regex: &str,
185) -> Result<String> {
186 let re = regex::Regex::new(regex).map_err(|e| anyhow::anyhow!("bad --target regex: {e}"))?;
187 let targets = backend.live_targets().await?;
188 targets
189 .into_iter()
190 .find(|t| re.is_match(&t.url))
191 .map(|t| t.id)
192 .ok_or_else(|| anyhow::anyhow!("no tab matched `{regex}`"))
193}
194
195#[cfg(test)]
196mod tests {
197
198 #[test]
202 fn trailing_newline_is_stripped_but_inner_content_kept() {
203 let cases = [
205 ("hunter2\n", "hunter2"),
206 ("hunter2\r\n", "hunter2"),
207 ("hunter2", "hunter2"),
208 ];
209 for (raw, want) in cases {
210 let v = raw.strip_suffix('\n').unwrap_or(raw);
211 let v = v.strip_suffix('\r').unwrap_or(v);
212 assert_eq!(v, want);
213 }
214 }
215
216 #[test]
217 fn a_password_may_contain_spaces_and_symbols() {
218 let raw = "p@ss word!+/=\n";
219 let v = raw.strip_suffix('\n').unwrap();
220 assert_eq!(v, "p@ss word!+/=");
221 assert!(!v.trim().is_empty());
222 assert!(!v.contains('\n'));
223 }
224}