Pipe Android/ADB logs into CLI (#4853)
* Enhance Android log handling in AppBuilder - Introduced adb_logcat fields to manage Android logcat output. - Implemented logcat spawning and parsing to differentiate log levels. - Updated push_stdio method to filter Android logs based on trace settings. - Added utility functions for retrieving app PID and parsing logcat levels. * automatic retry to get pid * pipe RustStdoutStderr as well * strip logcat prefix * filter in rust instead * Refine log filtering in Rust output handling - Improved log level detection by checking for prefixes after the colon in messages. - Enhanced filtering to only show logs with standard tracing levels or fatal errors. - Streamlined the process of stripping logcat metadata while preserving message content. * cleanup output and kill proc * capture logs from ios device * clippy --------- Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
This commit is contained in:
1 parent
24df6439ea
commit
6e3f362872
2 files changed
+162
-12
No files matched your search
@@ -2,7 +2,7 @@ use crate::{
|
|||||||
serve::WebServer, verbosity_or_default, BuildArtifacts, BuildRequest, BuildStage,
|
serve::WebServer, verbosity_or_default, BuildArtifacts, BuildRequest, BuildStage,
|
||||||
BuilderUpdate, BundleFormat, ProgressRx, ProgressTx, Result, RustcArgs, StructuredOutput,
|
BuilderUpdate, BundleFormat, ProgressRx, ProgressTx, Result, RustcArgs, StructuredOutput,
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Context};
|
use anyhow::{bail, Context, Error};
|
||||||
use dioxus_cli_opt::process_file_to;
|
use dioxus_cli_opt::process_file_to;
|
||||||
use futures_util::{future::OptionFuture, pin_mut, FutureExt};
|
use futures_util::{future::OptionFuture, pin_mut, FutureExt};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
@@ -22,6 +22,7 @@ use tokio::{
|
|||||||
process::{Child, ChildStderr, ChildStdout, Command},
|
process::{Child, ChildStderr, ChildStdout, Command},
|
||||||
task::JoinHandle,
|
task::JoinHandle,
|
||||||
};
|
};
|
||||||
|
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||||
|
|
||||||
use super::{BuildContext, BuildId, BuildMode, HotpatchModuleCache};
|
use super::{BuildContext, BuildId, BuildMode, HotpatchModuleCache};
|
||||||
|
|
||||||
@@ -78,6 +79,12 @@ pub(crate) struct AppBuilder {
|
|||||||
pub stdout: Option<Lines<BufReader<ChildStdout>>>,
|
pub stdout: Option<Lines<BufReader<ChildStdout>>>,
|
||||||
pub stderr: Option<Lines<BufReader<ChildStderr>>>,
|
pub stderr: Option<Lines<BufReader<ChildStderr>>>,
|
||||||
|
|
||||||
|
// Android logcat stream (treated as stderr for error/warn levels)
|
||||||
|
pub adb_logcat_stdout: Option<UnboundedReceiverStream<String>>,
|
||||||
|
|
||||||
|
/// Handle to the task that's monitoring the child process
|
||||||
|
pub spawn_handle: Option<JoinHandle<Result<()>>>,
|
||||||
|
|
||||||
/// The executables but with some extra entropy in their name so we can run two instances of the
|
/// The executables but with some extra entropy in their name so we can run two instances of the
|
||||||
/// same app without causing collisions on the filesystem.
|
/// same app without causing collisions on the filesystem.
|
||||||
pub entropy_app_exe: Option<PathBuf>,
|
pub entropy_app_exe: Option<PathBuf>,
|
||||||
@@ -146,6 +153,8 @@ impl AppBuilder {
|
|||||||
child: None,
|
child: None,
|
||||||
stderr: None,
|
stderr: None,
|
||||||
stdout: None,
|
stdout: None,
|
||||||
|
adb_logcat_stdout: None,
|
||||||
|
spawn_handle: None,
|
||||||
entropy_app_exe: None,
|
entropy_app_exe: None,
|
||||||
artifacts: None,
|
artifacts: None,
|
||||||
patch_cache: None,
|
patch_cache: None,
|
||||||
@@ -200,6 +209,25 @@ impl AppBuilder {
|
|||||||
Some(Ok(Some(msg))) = OptionFuture::from(self.stderr.as_mut().map(|f| f.next_line())) => {
|
Some(Ok(Some(msg))) = OptionFuture::from(self.stderr.as_mut().map(|f| f.next_line())) => {
|
||||||
StderrReceived { msg }
|
StderrReceived { msg }
|
||||||
},
|
},
|
||||||
|
Some(msg) = OptionFuture::from(self.spawn_handle.as_mut()) => {
|
||||||
|
match msg {
|
||||||
|
Ok(Ok(_)) => StdoutReceived { msg: "Finished launching app".to_string() },
|
||||||
|
Ok(Err(err)) => StderrReceived { msg: err.to_string() },
|
||||||
|
Err(err) => StderrReceived { msg: err.to_string() }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some(Some(msg)) = OptionFuture::from(self.adb_logcat_stdout.as_mut().map(|s| s.next())) => {
|
||||||
|
// Send as stderr for errors/warnings, stdout for info/debug
|
||||||
|
// Parse the priority level from a logcat line
|
||||||
|
//
|
||||||
|
// Logcat brief format: "I/TAG(12345): message"
|
||||||
|
// Returns the priority char (V, D, I, W, E, F)
|
||||||
|
if matches!(msg.chars().next().unwrap_or('I'), 'E' | 'W' | 'F') {
|
||||||
|
StderrReceived { msg }
|
||||||
|
} else {
|
||||||
|
StdoutReceived { msg }
|
||||||
|
}
|
||||||
|
},
|
||||||
Some(status) = OptionFuture::from(self.child.as_mut().map(|f| f.wait())) => {
|
Some(status) = OptionFuture::from(self.child.as_mut().map(|f| f.wait())) => {
|
||||||
match status {
|
match status {
|
||||||
Ok(status) => {
|
Ok(status) => {
|
||||||
@@ -562,8 +590,8 @@ impl AppBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
BundleFormat::Ios => {
|
BundleFormat::Ios => {
|
||||||
if let Some(device) = self.build.device_name.as_deref() {
|
if let Some(device) = self.build.device_name.to_owned() {
|
||||||
self.open_ios_device(device).await?
|
self.open_ios_device(&device).await?
|
||||||
} else {
|
} else {
|
||||||
self.open_ios_sim(envs).await?
|
self.open_ios_sim(envs).await?
|
||||||
}
|
}
|
||||||
@@ -631,6 +659,11 @@ impl AppBuilder {
|
|||||||
if let Some(entropy_app_exe) = self.entropy_app_exe.take() {
|
if let Some(entropy_app_exe) = self.entropy_app_exe.take() {
|
||||||
_ = std::fs::remove_file(entropy_app_exe);
|
_ = std::fs::remove_file(entropy_app_exe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Abort the spawn handle monitoring task if it exists
|
||||||
|
if let Some(spawn_handle) = self.spawn_handle.take() {
|
||||||
|
spawn_handle.abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn hotpatch(
|
pub(crate) async fn hotpatch(
|
||||||
@@ -904,10 +937,10 @@ impl AppBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Upload the app to the device and launch it
|
/// Upload the app to the device and launch it
|
||||||
async fn open_ios_device(&self, device_query: &str) -> Result<()> {
|
async fn open_ios_device(&mut self, device_query: &str) -> Result<()> {
|
||||||
let device_query = device_query.to_string();
|
let device_query = device_query.to_string();
|
||||||
let root_dir = self.build.root_dir().clone();
|
let root_dir = self.build.root_dir().clone();
|
||||||
tokio::task::spawn(async move {
|
self.spawn_handle = Some(tokio::task::spawn(async move {
|
||||||
// 1. Find an active device
|
// 1. Find an active device
|
||||||
let device_uuid = Self::get_ios_device_uuid(&device_query).await?;
|
let device_uuid = Self::get_ios_device_uuid(&device_query).await?;
|
||||||
|
|
||||||
@@ -920,7 +953,7 @@ impl AppBuilder {
|
|||||||
Self::launch_ios_app_paused(&device_uuid, &installation_url).await?;
|
Self::launch_ios_app_paused(&device_uuid, &installation_url).await?;
|
||||||
|
|
||||||
Result::Ok(()) as Result<()>
|
Result::Ok(()) as Result<()>
|
||||||
});
|
}));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1187,7 +1220,7 @@ impl AppBuilder {
|
|||||||
/// # Resources:
|
/// # Resources:
|
||||||
/// - <https://developer.android.com/studio/run/emulator-commandline>
|
/// - <https://developer.android.com/studio/run/emulator-commandline>
|
||||||
async fn open_android(
|
async fn open_android(
|
||||||
&self,
|
&mut self,
|
||||||
root: bool,
|
root: bool,
|
||||||
devserver_socket: SocketAddr,
|
devserver_socket: SocketAddr,
|
||||||
envs: Vec<(String, String)>,
|
envs: Vec<(String, String)>,
|
||||||
@@ -1197,9 +1230,10 @@ impl AppBuilder {
|
|||||||
let session_cache = self.build.session_cache_dir();
|
let session_cache = self.build.session_cache_dir();
|
||||||
let application_id = self.build.bundle_identifier();
|
let application_id = self.build.bundle_identifier();
|
||||||
let adb = self.build.workspace.android_tools()?.adb.clone();
|
let adb = self.build.workspace.android_tools()?.adb.clone();
|
||||||
|
let (stdout_tx, stdout_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||||
|
|
||||||
// Start backgrounded since .open() is called while in the arm of the top-level match
|
// Start backgrounded since .open() is called while in the arm of the top-level match
|
||||||
let _handle: JoinHandle<Result<()>> = tokio::task::spawn(async move {
|
let task = tokio::task::spawn(async move {
|
||||||
// call `adb root` so we can push patches to the device
|
// call `adb root` so we can push patches to the device
|
||||||
if root {
|
if root {
|
||||||
if let Err(e) = Command::new(&adb).arg("root").output().await {
|
if let Err(e) = Command::new(&adb).arg("root").output().await {
|
||||||
@@ -1210,7 +1244,7 @@ impl AppBuilder {
|
|||||||
// Try to get the transport ID for the device in case there are multiple specified devices
|
// Try to get the transport ID for the device in case there are multiple specified devices
|
||||||
// All future commands should use this since its the most recent.
|
// All future commands should use this since its the most recent.
|
||||||
let transport_id_args =
|
let transport_id_args =
|
||||||
Self::get_android_device_transport_id(&adb, device_name_query).await;
|
Self::get_android_device_transport_id(&adb, device_name_query.as_deref()).await;
|
||||||
|
|
||||||
// Wait for device to be ready
|
// Wait for device to be ready
|
||||||
let cmd = Command::new(&adb)
|
let cmd = Command::new(&adb)
|
||||||
@@ -1296,9 +1330,66 @@ impl AppBuilder {
|
|||||||
tracing::error!("Failed to start app with `adb`: {std_err}");
|
tracing::error!("Failed to start app with `adb`: {std_err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
// Try to get the transport ID for the device
|
||||||
|
let transport_id_args =
|
||||||
|
Self::get_android_device_transport_id(&adb, device_name_query.as_deref()).await;
|
||||||
|
|
||||||
|
// Get the app's PID with retries
|
||||||
|
// Retry up to 10 times (10 seconds total) since app launch is asynchronous
|
||||||
|
let mut pid: Option<String> = None;
|
||||||
|
for attempt in 1..=10 {
|
||||||
|
match Self::get_android_app_pid(&adb, &application_id, &transport_id_args).await {
|
||||||
|
Ok(p) => {
|
||||||
|
pid = Some(p);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(_) if attempt < 10 => {
|
||||||
|
tracing::debug!(
|
||||||
|
"App PID not found yet, retrying in 1 second... (attempt {}/10)",
|
||||||
|
attempt
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(e).context(
|
||||||
|
"Failed to get app PID after 10 attempts - app may not have started",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pid = pid.context("Failed to get app PID")?;
|
||||||
|
|
||||||
|
// Spawn logcat with filtering
|
||||||
|
// By default: show only RustStdoutStderr (app Rust logs) and fatal errors
|
||||||
|
// With tracing enabled: show all logs from the app process
|
||||||
|
// Note: We always capture at DEBUG level, then filter in Rust based on trace flag
|
||||||
|
let mut child = Command::new(&adb)
|
||||||
|
.args(&transport_id_args)
|
||||||
|
.arg("logcat")
|
||||||
|
.arg("-v")
|
||||||
|
.arg("brief")
|
||||||
|
.arg("--pid")
|
||||||
|
.arg(&pid)
|
||||||
|
.arg("*:D") // Capture all logs at DEBUG level (filtered in Rust)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
let stdout = child.stdout.take().unwrap();
|
||||||
|
let mut reader = BufReader::new(stdout).lines();
|
||||||
|
|
||||||
|
while let Ok(Some(line)) = reader.next_line().await {
|
||||||
|
_ = stdout_tx.send(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok::<(), Error>(())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.spawn_handle = Some(task);
|
||||||
|
self.adb_logcat_stdout = Some(UnboundedReceiverStream::new(stdout_rx));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1570,7 +1661,7 @@ impl AppBuilder {
|
|||||||
|
|
||||||
async fn get_android_device_transport_id(
|
async fn get_android_device_transport_id(
|
||||||
adb: &PathBuf,
|
adb: &PathBuf,
|
||||||
device_name_query: Option<String>,
|
device_name_query: Option<&str>,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
// If there are multiple devices, we pick the one matching the query
|
// If there are multiple devices, we pick the one matching the query
|
||||||
let mut device_specifier_args = vec![];
|
let mut device_specifier_args = vec![];
|
||||||
@@ -1621,4 +1712,27 @@ impl AppBuilder {
|
|||||||
|
|
||||||
device_specifier_args
|
device_specifier_args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the PID of the running Android app
|
||||||
|
async fn get_android_app_pid(
|
||||||
|
adb: &Path,
|
||||||
|
application_id: &str,
|
||||||
|
transport_id_args: &[String],
|
||||||
|
) -> Result<String> {
|
||||||
|
let output = Command::new(adb)
|
||||||
|
.args(transport_id_args)
|
||||||
|
.arg("shell")
|
||||||
|
.arg("pidof")
|
||||||
|
.arg(application_id)
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let pid = String::from_utf8(output.stdout)?.trim().to_string();
|
||||||
|
|
||||||
|
if pid.is_empty() {
|
||||||
|
anyhow::bail!("App process not found - may not have started yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(pid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -337,7 +337,43 @@ impl Output {
|
|||||||
/// This will queue the stderr message as a TraceMsg and print it on the next render
|
/// This will queue the stderr message as a TraceMsg and print it on the next render
|
||||||
/// We'll use the `App` TraceSrc for the msg, and whatever level is provided
|
/// We'll use the `App` TraceSrc for the msg, and whatever level is provided
|
||||||
pub fn push_stdio(&mut self, bundle: BundleFormat, msg: String, level: Level) {
|
pub fn push_stdio(&mut self, bundle: BundleFormat, msg: String, level: Level) {
|
||||||
self.push_log(TraceMsg::text(TraceSrc::App(bundle), level, msg));
|
match bundle {
|
||||||
|
// If tracing is disabled, we need to filter out all the noise from Android logcat
|
||||||
|
BundleFormat::Android if !self.trace => {
|
||||||
|
// By default (trace off): only show RustStdoutStderr logs with proper log levels
|
||||||
|
// Filter out raw output like GL bindings, WebView internals, etc.
|
||||||
|
let is_rust_log = msg.contains("RustStdoutStderr");
|
||||||
|
let is_fatal = msg.contains("AndroidRuntime") || msg.starts_with('F');
|
||||||
|
let mut rendered_msg = None;
|
||||||
|
|
||||||
|
// If we're not in trace mode, then we need to filter out non-log messages and clean them up
|
||||||
|
// Only show logs with standard tracing level prefixes (check in the message after the colon)
|
||||||
|
if is_rust_log {
|
||||||
|
if let Some(colon_pos) = msg.find(':') {
|
||||||
|
let content = &msg[colon_pos + 1..];
|
||||||
|
if content.contains(" TRACE ")
|
||||||
|
|| content.contains(" DEBUG ")
|
||||||
|
|| content.contains(" INFO ")
|
||||||
|
|| content.contains(" WARN ")
|
||||||
|
|| content.contains(" ERROR ")
|
||||||
|
{
|
||||||
|
rendered_msg = Some(content.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show fatal errors, even if they don't come from Rust logging
|
||||||
|
if is_fatal {
|
||||||
|
rendered_msg = Some(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(msg) = rendered_msg {
|
||||||
|
self.push_log(TraceMsg::text(TraceSrc::App(bundle), level, msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => self.push_log(TraceMsg::text(TraceSrc::App(bundle), level, msg)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push a message from the websocket to the logs
|
/// Push a message from the websocket to the logs
|
||||||
|
|||||||
Reference in new issue
Block a user