Skip to content

Commit c804570

Browse files
authored
Merge pull request #95 from httpsphl/feat/unix-runtime
feat: bots and browsers on Linux and macOS
2 parents 39e50e2 + 0f7e205 commit c804570

19 files changed

Lines changed: 705 additions & 121 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ jobs:
5252
components: clippy
5353
- uses: Swatinem/rust-cache@v2
5454
- run: cargo clippy --all-targets -- -D warnings
55+
# The runner image ships Edge and Chrome with sandbox helpers that are
56+
# not setuid root, as their packages would leave them, so they refuse
57+
# to start. The browser tests need a working sandbox.
58+
- name: Browser sandbox helpers as their packages set them
59+
if: runner.os == 'Linux'
60+
run: |
61+
for helper in /opt/microsoft/msedge/msedge-sandbox /opt/google/chrome/chrome-sandbox; do
62+
if [ -f "$helper" ]; then
63+
sudo chown root:root "$helper"
64+
sudo chmod 4755 "$helper"
65+
fi
66+
done
5567
- run: cargo test
5668

5769
app:

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/botloftd/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security"
4040
# Named by the code that `windows::core::implement` writes.
4141
windows-core = "0.62.2"
4242

43+
[target."cfg(unix)".dependencies]
44+
libc = "0.2.189"
45+
4346
[target."cfg(windows)".build-dependencies]
4447
embed-resource = "3.0.11"
4548

‎crates/botloftd/src/browser/call.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use tokio::sync::OwnedMutexGuard;
1111
use tracing::debug;
1212

1313
use super::session::{Hooks, PageInfo, Session};
14-
use super::{BrowserError, Browsers, launch, lock, update, watch};
14+
use super::{BrowserError, Browsers, lock, program, update, watch};
1515

1616
pub struct Call<'a> {
1717
pub(super) browsers: &'a Browsers,
@@ -39,7 +39,7 @@ impl Call<'_> {
3939
state.error = None;
4040
});
4141
let start = browsers.starts.fetch_add(1, Ordering::Relaxed);
42-
let started = match launch::find(&browsers.settings.path) {
42+
let started = match program::find(&browsers.settings.path) {
4343
None => Err(BrowserError::NotFound),
4444
Some(program) => {
4545
let profile = browsers.profiles.join(bot.as_str());

‎crates/botloftd/src/browser/input.rs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,16 +170,52 @@ fn press(key: &str, code: &str, modifiers: u32) -> Vec<(&'static str, Value)> {
170170
down["text"] = json!(text);
171171
down["unmodifiedText"] = json!(text);
172172
}
173+
if let Some(command) = editing_command(key, modifiers, cfg!(target_os = "macos")) {
174+
down["commands"] = json!([command]);
175+
}
173176
vec![
174177
("Input.dispatchKeyEvent", down),
175178
("Input.dispatchKeyEvent", event("keyUp")),
176179
]
177180
}
178181

182+
/// On macOS the page runs no Cmd shortcut from the virtual key, so the
183+
/// editing command goes with the key; elsewhere Ctrl ones run by themselves.
184+
fn editing_command(key: &str, modifiers: u32, mac: bool) -> Option<&'static str> {
185+
let others = modifier::CTRL | modifier::ALT;
186+
if !mac || modifiers & modifier::META == 0 || modifiers & others != 0 {
187+
return None;
188+
}
189+
let shift = modifiers & modifier::SHIFT != 0;
190+
match (key.to_ascii_lowercase().as_str(), shift) {
191+
("a", false) => Some("selectAll"),
192+
("c", false) => Some("copy"),
193+
("x", false) => Some("cut"),
194+
("v", false) => Some("paste"),
195+
("z", false) => Some("undo"),
196+
("z", true) => Some("redo"),
197+
_ => None,
198+
}
199+
}
200+
179201
#[cfg(test)]
180202
mod tests {
181203
use super::*;
182204

205+
#[test]
206+
fn cmd_shortcuts_carry_their_command_on_macos_only() {
207+
let cmd = modifier::META;
208+
assert_eq!(editing_command("a", cmd, true), Some("selectAll"));
209+
assert_eq!(
210+
editing_command("Z", cmd | modifier::SHIFT, true),
211+
Some("redo")
212+
);
213+
assert_eq!(editing_command("a", cmd, false), None);
214+
assert_eq!(editing_command("a", modifier::CTRL, true), None);
215+
assert_eq!(editing_command("a", cmd | modifier::ALT, true), None);
216+
assert_eq!(editing_command("b", cmd, true), None);
217+
}
218+
183219
fn key(key: &str, code: &str, modifiers: u32) -> Vec<Value> {
184220
calls(&BrowserInput::Key {
185221
key: key.to_owned(),

‎crates/botloftd/src/browser/launch.rs‎

Lines changed: 108 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
1-
//! Starting a bot's browser (spec 21.2): find Edge, run it without a window
2-
//! in a job of its own, and read where its DevTools listen.
1+
//! Starting a bot's browser (spec 21.2): run it without a window in a job
2+
//! of its own, and read where its DevTools listen.
33
4-
use std::io;
5-
use std::path::{Path, PathBuf};
6-
use std::process::{Child, Command, Stdio};
4+
use std::io::{self, BufRead as _, BufReader};
5+
use std::path::Path;
6+
use std::process::{Child, ChildStderr, Command, Stdio};
7+
use std::sync::{Arc, Mutex, mpsc};
78
use std::time::{Duration, Instant};
89

910
use tracing::debug;
1011

1112
use super::BrowserError;
1213
use crate::platform::{self, ProcessJob};
1314

14-
/// How long Edge may take to write `DevToolsActivePort`.
15-
const START_TIMEOUT: Duration = Duration::from_secs(10);
15+
/// How long the browser may take to write `DevToolsActivePort`. Usually
16+
/// under a second; Edge's first start on a new Linux machine took over 20.
17+
const START_TIMEOUT: Duration = Duration::from_secs(30);
1618
const PORT_FILE: &str = "DevToolsActivePort";
19+
/// Longest stderr line passed on as the reason a browser stopped.
20+
const REASON_MAX: usize = 300;
1721

1822
/// The browser's process tree. Dropping it kills every process in it.
1923
pub struct BrowserProcess {
@@ -35,26 +39,6 @@ impl Drop for BrowserProcess {
3539
}
3640
}
3741

38-
/// The browser to run: `configured`, or the Edge that comes with Windows.
39-
pub fn find(configured: &str) -> Option<PathBuf> {
40-
let configured = configured.trim();
41-
if !configured.is_empty() {
42-
let path = PathBuf::from(configured);
43-
return path.is_file().then_some(path);
44-
}
45-
["ProgramFiles(x86)", "ProgramFiles", "LOCALAPPDATA"]
46-
.iter()
47-
.filter_map(std::env::var_os)
48-
.map(|base| {
49-
PathBuf::from(base)
50-
.join("Microsoft")
51-
.join("Edge")
52-
.join("Application")
53-
.join("msedge.exe")
54-
})
55-
.find(|path| path.is_file())
56-
}
57-
5842
/// Starts `program` with the profile in `profile` and returns it with the
5943
/// DevTools WebSocket address.
6044
pub async fn launch(
@@ -88,20 +72,16 @@ pub async fn launch(
8872
.envs(env.iter().map(|(name, value)| (name, value)))
8973
.stdin(Stdio::null())
9074
.stdout(Stdio::null())
91-
.stderr(Stdio::null());
92-
#[cfg(windows)]
93-
{
94-
use std::os::windows::process::CommandExt as _;
95-
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
96-
command.creation_flags(CREATE_NO_WINDOW);
97-
}
75+
.stderr(Stdio::piped());
76+
ProcessJob::prepare(&mut command);
9877
let job = ProcessJob::new()?;
99-
let child = command.spawn()?;
100-
#[cfg(windows)]
101-
{
102-
use std::os::windows::io::AsRawHandle as _;
103-
job.assign(child.as_raw_handle())?;
78+
let mut child = command.spawn()?;
79+
if let Err(err) = job.assign(&child) {
80+
let _ = child.kill();
81+
let _ = child.wait();
82+
return Err(err.into());
10483
}
84+
let said = child.stderr.take().map(StartLog::read);
10585
let mut process = BrowserProcess { child, job };
10686
debug!(pid = process.child.id(), "browser: started");
10787

@@ -114,19 +94,94 @@ pub async fn launch(
11494
return Ok((process, url));
11595
}
11696
if process.child.try_wait()?.is_some() {
117-
return Err(BrowserError::Start(
118-
"the browser closed right after starting".to_owned(),
119-
));
97+
// Its helpers may still hold stderr open until the group dies.
98+
process.kill();
99+
if let Some(said) = &said {
100+
let _ = said.ended.recv_timeout(Duration::from_secs(1));
101+
}
102+
return Err(BrowserError::Start(format!(
103+
"the browser closed right after starting{}",
104+
StartLog::reason(said.as_ref())
105+
)));
120106
}
121107
if started.elapsed() > START_TIMEOUT {
122-
return Err(BrowserError::Start(
123-
"the browser did not start in time".to_owned(),
124-
));
108+
return Err(BrowserError::Start(format!(
109+
"the browser did not start in time{}",
110+
StartLog::reason(said.as_ref())
111+
)));
125112
}
126113
tokio::time::sleep(Duration::from_millis(50)).await;
127114
}
128115
}
129116

117+
/// What the browser writes to stderr, kept as the line that best says why
118+
/// it failed to start (a missing sandbox on Linux, say). Nothing of it goes
119+
/// to the log.
120+
struct StartLog {
121+
lines: Arc<Mutex<Said>>,
122+
/// Hears when stderr ends.
123+
ended: mpsc::Receiver<()>,
124+
}
125+
126+
#[derive(Default)]
127+
struct Said {
128+
best: Option<String>,
129+
last: Option<String>,
130+
}
131+
132+
impl StartLog {
133+
fn read(stderr: ChildStderr) -> Self {
134+
let lines = Arc::new(Mutex::new(Said::default()));
135+
let (end, ended) = mpsc::channel();
136+
let shared = Arc::clone(&lines);
137+
let spawned = std::thread::Builder::new()
138+
.name("browser-stderr".into())
139+
.spawn(move || {
140+
for line in BufReader::new(stderr).lines() {
141+
let Ok(line) = line else { break };
142+
let text: String = log_text(&line).chars().take(REASON_MAX).collect();
143+
if text.is_empty() {
144+
continue;
145+
}
146+
let lower = line.to_ascii_lowercase();
147+
let mut said = shared.lock().unwrap_or_else(|p| p.into_inner());
148+
let sandbox = lower.contains("sandbox") || lower.contains("namespace");
149+
if sandbox || (said.best.is_none() && lower.contains(":fatal:")) {
150+
said.best = Some(text.clone());
151+
}
152+
said.last = Some(text);
153+
}
154+
let _ = end.send(());
155+
});
156+
if spawned.is_err() {
157+
debug!("browser: cannot read its stderr");
158+
}
159+
Self { lines, ended }
160+
}
161+
162+
/// ` (the line)`, or nothing when the browser said nothing.
163+
fn reason(log: Option<&Self>) -> String {
164+
let Some(log) = log else {
165+
return String::new();
166+
};
167+
let said = log.lines.lock().unwrap_or_else(|p| p.into_inner());
168+
said.best
169+
.as_ref()
170+
.or(said.last.as_ref())
171+
.map(|line| format!(" ({line})"))
172+
.unwrap_or_default()
173+
}
174+
}
175+
176+
/// A Chromium log line without its `[pid:tid:time:LEVEL:file.cc:123]` prefix.
177+
fn log_text(line: &str) -> String {
178+
let text = match line.strip_prefix('[') {
179+
Some(rest) => rest.split_once("] ").map_or(line, |(_, text)| text),
180+
None => line,
181+
};
182+
text.trim().to_owned()
183+
}
184+
130185
/// `DevToolsActivePort` holds the port and the browser's WebSocket path, one
131186
/// per line. The file may be read half-written.
132187
fn devtools_url(text: &str) -> Option<String> {
@@ -153,7 +208,13 @@ mod tests {
153208
}
154209

155210
#[test]
156-
fn a_configured_browser_must_exist() {
157-
assert_eq!(find(r"C:\nowhere\msedge.exe"), None);
211+
fn a_log_line_loses_its_prefix() {
212+
assert_eq!(
213+
log_text(
214+
"[11:11:1001/173604.487235:ERROR:zygote_host_impl_linux.cc:129] No usable sandbox!"
215+
),
216+
"No usable sandbox!"
217+
);
218+
assert_eq!(log_text(" plain words "), "plain words");
158219
}
159220
}

‎crates/botloftd/src/browser/mod.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod keys;
1212
mod launch;
1313
mod moves;
1414
mod page;
15+
mod program;
1516
mod read;
1617
mod rest;
1718
mod session;
@@ -38,8 +39,8 @@ use tracing::{debug, warn};
3839
pub use self::call::Call;
3940
pub use self::hands::{Asking, Hands, InputError, OWNER_WAIT, TakeError};
4041
pub use self::keys::{Key, find as find_key, names as key_names};
41-
pub use self::launch::find as find_program;
4242
pub use self::page::{Aim, Done, Scroll};
43+
pub use self::program::find as find_program;
4344
pub use self::read::{READ_MAX, Reading};
4445
pub use self::session::Session;
4546
pub use self::sweep::{Want, run};
@@ -51,7 +52,7 @@ use crate::state::Event;
5152

5253
#[derive(Debug, thiserror::Error)]
5354
pub enum BrowserError {
54-
#[error("Microsoft Edge was not found on this computer")]
55+
#[error("{}", program::NOT_FOUND)]
5556
NotFound,
5657
#[error("the browser could not start: {0}")]
5758
Start(String),

0 commit comments

Comments
 (0)