Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,40 @@ jobs:
- name: Generated TypeScript bindings are up to date
run: git diff --exit-code -- app/src/lib/protocol.gen.ts

rust-ubuntu:
name: Rust core + store (Ubuntu)
runs-on: ubuntu-latest
# The daemon and its crates off Windows (spec 18, item 8). Plain `cargo`
# builds the default members only: the app crates need the desktop
# libraries of each system and join later.
rust-unix:
name: Rust (${{ matrix.name }})
strategy:
fail-fast: false
matrix:
include:
- name: Ubuntu
os: ubuntu-latest
- name: macOS
os: macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy -p botloft-core -p botloft-store --all-targets -- -D warnings
- run: cargo test -p botloft-core -p botloft-store
- run: cargo clippy --all-targets -- -D warnings
# The runner image ships Edge and Chrome with sandbox helpers that are
# not setuid root, as their packages would leave them, so they refuse
# to start. The browser tests need a working sandbox.
- name: Browser sandbox helpers as their packages set them
if: runner.os == 'Linux'
run: |
for helper in /opt/microsoft/msedge/msedge-sandbox /opt/google/chrome/chrome-sandbox; do
if [ -f "$helper" ]; then
sudo chown root:root "$helper"
sudo chmod 4755 "$helper"
fi
done
- run: cargo test

app:
name: App (Windows)
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/botloftd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security"
# Named by the code that `windows::core::implement` writes.
windows-core = "0.62.2"

[target."cfg(unix)".dependencies]
libc = "0.2.189"

[target."cfg(windows)".build-dependencies]
embed-resource = "3.0.11"

Expand Down
13 changes: 12 additions & 1 deletion crates/botloftd/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,18 @@ fn main() {
let out =
PathBuf::from(env::var_os("OUT_DIR").expect("cargo sets OUT_DIR")).join("botloftd.rc");
fs::write(&out, script).expect("cannot write the resource script");
if let Err(err) = embed_resource::compile(&out, embed_resource::NONE).manifest_required() {
embed(&out);
}

/// `embed-resource` is a build dependency on Windows hosts only.
#[cfg(windows)]
fn embed(script: &std::path::Path) {
if let Err(err) = embed_resource::compile(script, embed_resource::NONE).manifest_required() {
panic!("cannot embed the resources: {err}");
}
}

#[cfg(not(windows))]
fn embed(_script: &std::path::Path) {
panic!("the Windows daemon is built on Windows, where its resources can be embedded");
}
11 changes: 9 additions & 2 deletions crates/botloftd/src/autostart/binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ mod tests {
names
}

/// The old copy and the installed binary, in the order `names` gives.
fn old_and_installed() -> Vec<String> {
let mut names = vec!["botloftd.1.old".to_owned(), EXE_NAME.to_owned()];
names.sort();
names
}

#[test]
fn copies_once_and_replaces_only_a_different_binary() {
let dir = tempfile::tempdir().expect("tempdir");
Expand All @@ -129,13 +136,13 @@ mod tests {
let second = install(&source, &bin).expect("update");
assert!(second.changed);
assert_eq!(fs::read(&second.path).expect("read"), b"v2");
assert_eq!(names(&bin), ["botloftd.1.old".to_owned(), EXE_NAME.into()]);
assert_eq!(names(&bin), old_and_installed());

fs::write(&source, b"v3").expect("write");
install(&source, &bin).expect("update");
assert_eq!(
names(&bin),
["botloftd.1.old".to_owned(), EXE_NAME.into()],
old_and_installed(),
"the v1 copy went away and v2 moved aside"
);

Expand Down
4 changes: 2 additions & 2 deletions crates/botloftd/src/browser/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use tokio::sync::OwnedMutexGuard;
use tracing::debug;

use super::session::{Hooks, PageInfo, Session};
use super::{BrowserError, Browsers, launch, lock, update, watch};
use super::{BrowserError, Browsers, lock, program, update, watch};

pub struct Call<'a> {
pub(super) browsers: &'a Browsers,
Expand Down Expand Up @@ -39,7 +39,7 @@ impl Call<'_> {
state.error = None;
});
let start = browsers.starts.fetch_add(1, Ordering::Relaxed);
let started = match launch::find(&browsers.settings.path) {
let started = match program::find(&browsers.settings.path) {
None => Err(BrowserError::NotFound),
Some(program) => {
let profile = browsers.profiles.join(bot.as_str());
Expand Down
36 changes: 36 additions & 0 deletions crates/botloftd/src/browser/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,16 +170,52 @@ fn press(key: &str, code: &str, modifiers: u32) -> Vec<(&'static str, Value)> {
down["text"] = json!(text);
down["unmodifiedText"] = json!(text);
}
if let Some(command) = editing_command(key, modifiers, cfg!(target_os = "macos")) {
down["commands"] = json!([command]);
}
vec![
("Input.dispatchKeyEvent", down),
("Input.dispatchKeyEvent", event("keyUp")),
]
}

/// On macOS the page runs no Cmd shortcut from the virtual key, so the
/// editing command goes with the key; elsewhere Ctrl ones run by themselves.
fn editing_command(key: &str, modifiers: u32, mac: bool) -> Option<&'static str> {
let others = modifier::CTRL | modifier::ALT;
if !mac || modifiers & modifier::META == 0 || modifiers & others != 0 {
return None;
}
let shift = modifiers & modifier::SHIFT != 0;
match (key.to_ascii_lowercase().as_str(), shift) {
("a", false) => Some("selectAll"),
("c", false) => Some("copy"),
("x", false) => Some("cut"),
("v", false) => Some("paste"),
("z", false) => Some("undo"),
("z", true) => Some("redo"),
_ => None,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn cmd_shortcuts_carry_their_command_on_macos_only() {
let cmd = modifier::META;
assert_eq!(editing_command("a", cmd, true), Some("selectAll"));
assert_eq!(
editing_command("Z", cmd | modifier::SHIFT, true),
Some("redo")
);
assert_eq!(editing_command("a", cmd, false), None);
assert_eq!(editing_command("a", modifier::CTRL, true), None);
assert_eq!(editing_command("a", cmd | modifier::ALT, true), None);
assert_eq!(editing_command("b", cmd, true), None);
}

fn key(key: &str, code: &str, modifiers: u32) -> Vec<Value> {
calls(&BrowserInput::Key {
key: key.to_owned(),
Expand Down
155 changes: 108 additions & 47 deletions crates/botloftd/src/browser/launch.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
//! Starting a bot's browser (spec 21.2): find Edge, run it without a window
//! in a job of its own, and read where its DevTools listen.
//! Starting a bot's browser (spec 21.2): run it without a window in a job
//! of its own, and read where its DevTools listen.

use std::io;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::io::{self, BufRead as _, BufReader};
use std::path::Path;
use std::process::{Child, ChildStderr, Command, Stdio};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};

use tracing::debug;

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

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

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

/// The browser to run: `configured`, or the Edge that comes with Windows.
pub fn find(configured: &str) -> Option<PathBuf> {
let configured = configured.trim();
if !configured.is_empty() {
let path = PathBuf::from(configured);
return path.is_file().then_some(path);
}
["ProgramFiles(x86)", "ProgramFiles", "LOCALAPPDATA"]
.iter()
.filter_map(std::env::var_os)
.map(|base| {
PathBuf::from(base)
.join("Microsoft")
.join("Edge")
.join("Application")
.join("msedge.exe")
})
.find(|path| path.is_file())
}

/// Starts `program` with the profile in `profile` and returns it with the
/// DevTools WebSocket address.
pub async fn launch(
Expand Down Expand Up @@ -88,20 +72,16 @@ pub async fn launch(
.envs(env.iter().map(|(name, value)| (name, value)))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
.stderr(Stdio::piped());
ProcessJob::prepare(&mut command);
let job = ProcessJob::new()?;
let child = command.spawn()?;
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle as _;
job.assign(child.as_raw_handle())?;
let mut child = command.spawn()?;
if let Err(err) = job.assign(&child) {
let _ = child.kill();
let _ = child.wait();
return Err(err.into());
}
let said = child.stderr.take().map(StartLog::read);
let mut process = BrowserProcess { child, job };
debug!(pid = process.child.id(), "browser: started");

Expand All @@ -114,19 +94,94 @@ pub async fn launch(
return Ok((process, url));
}
if process.child.try_wait()?.is_some() {
return Err(BrowserError::Start(
"the browser closed right after starting".to_owned(),
));
// Its helpers may still hold stderr open until the group dies.
process.kill();
if let Some(said) = &said {
let _ = said.ended.recv_timeout(Duration::from_secs(1));
}
return Err(BrowserError::Start(format!(
"the browser closed right after starting{}",
StartLog::reason(said.as_ref())
)));
}
if started.elapsed() > START_TIMEOUT {
return Err(BrowserError::Start(
"the browser did not start in time".to_owned(),
));
return Err(BrowserError::Start(format!(
"the browser did not start in time{}",
StartLog::reason(said.as_ref())
)));
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}

/// What the browser writes to stderr, kept as the line that best says why
/// it failed to start (a missing sandbox on Linux, say). Nothing of it goes
/// to the log.
struct StartLog {
lines: Arc<Mutex<Said>>,
/// Hears when stderr ends.
ended: mpsc::Receiver<()>,
}

#[derive(Default)]
struct Said {
best: Option<String>,
last: Option<String>,
}

impl StartLog {
fn read(stderr: ChildStderr) -> Self {
let lines = Arc::new(Mutex::new(Said::default()));
let (end, ended) = mpsc::channel();
let shared = Arc::clone(&lines);
let spawned = std::thread::Builder::new()
.name("browser-stderr".into())
.spawn(move || {
for line in BufReader::new(stderr).lines() {
let Ok(line) = line else { break };
let text: String = log_text(&line).chars().take(REASON_MAX).collect();
if text.is_empty() {
continue;
}
let lower = line.to_ascii_lowercase();
let mut said = shared.lock().unwrap_or_else(|p| p.into_inner());
let sandbox = lower.contains("sandbox") || lower.contains("namespace");
if sandbox || (said.best.is_none() && lower.contains(":fatal:")) {
said.best = Some(text.clone());
}
said.last = Some(text);
}
let _ = end.send(());
});
if spawned.is_err() {
debug!("browser: cannot read its stderr");
}
Self { lines, ended }
}

/// ` (the line)`, or nothing when the browser said nothing.
fn reason(log: Option<&Self>) -> String {
let Some(log) = log else {
return String::new();
};
let said = log.lines.lock().unwrap_or_else(|p| p.into_inner());
said.best
.as_ref()
.or(said.last.as_ref())
.map(|line| format!(" ({line})"))
.unwrap_or_default()
}
}

/// A Chromium log line without its `[pid:tid:time:LEVEL:file.cc:123]` prefix.
fn log_text(line: &str) -> String {
let text = match line.strip_prefix('[') {
Some(rest) => rest.split_once("] ").map_or(line, |(_, text)| text),
None => line,
};
text.trim().to_owned()
}

/// `DevToolsActivePort` holds the port and the browser's WebSocket path, one
/// per line. The file may be read half-written.
fn devtools_url(text: &str) -> Option<String> {
Expand All @@ -153,7 +208,13 @@ mod tests {
}

#[test]
fn a_configured_browser_must_exist() {
assert_eq!(find(r"C:\nowhere\msedge.exe"), None);
fn a_log_line_loses_its_prefix() {
assert_eq!(
log_text(
"[11:11:1001/173604.487235:ERROR:zygote_host_impl_linux.cc:129] No usable sandbox!"
),
"No usable sandbox!"
);
assert_eq!(log_text(" plain words "), "plain words");
}
}
Loading
Loading