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
4 changes: 4 additions & 0 deletions .github/workflows/v2-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ jobs:
working-directory: v2
run: npm run test:recommendation-labels

- name: Device log snapshots and lifecycle
working-directory: v2
run: npm run test:device-logs

- name: Launcher rows
working-directory: v2
run: npm run test:launcher-rows
Expand Down
28 changes: 28 additions & 0 deletions docs/DEVICE-LOGS-2026-10-08.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Desktop device logs

The Logs tab addresses #162 with finite, read-only logcat snapshots. It is not a lossless
stream or a persistent recording. The most recent snapshot replaces the preceding one;
entries can repeat between reads and high-volume traffic can roll out of the selected window.

- Reads start only on an explicit Read logs or Auto-refresh click.
- Severity, one exact tag and an optional Android package constrain the device-side read.
Package filtering resolves the running main PID for each snapshot. Missing, ambiguous or
unsupported PID resolution fails without requesting unfiltered logs. Helper processes are
excluded; Android PID reuse means historical entries can belong to an earlier process.
- Every shell invocation uses the existing bounded driver: 30 seconds and 256 KiB per stream.
App filtering adds a separate bounded PID lookup. Only one read is pending per view.
- Auto-refresh waits three seconds after a completed read. It stops on error, tab departure,
device-page reset, document hiding or explicit Stop. A native read already in flight may
finish within its bounds, but its late result is discarded. Returning does not resume it.
- A tag cannot start with a hyphen or contain shell/filter syntax. The typed command exposes
no log-clearing, buffer resizing, system-setting or arbitrary-shell option.
- Raw text is escaped on screen. Find filters only the displayed snapshot. This feature does
not save, upload or append logs to bug reports; the shared bounded driver redacts payloads
from its own debug/session records. Review sensitive text before manually sharing it.
- Timeout/output-cap results are labeled partial. Android versions may reject logcat flags;
device errors remain visible rather than falling back to broader capture.

Validation covers pure filter/command construction, mocked driver failures and PID scope,
polling lifecycle, and browser interaction. Those checks do not substitute for a physical-TV
compatibility pass. The screenshot gallery also needs regeneration before release because
the device navigation includes a new tab. Mobile has not been wired to this command.
4 changes: 4 additions & 0 deletions v2/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ this file and shows the newest few sections, dated, from its version button.

## Unreleased

### Added

- Add a read-only desktop Logs tab with severity, tag and main-process filters, bounded recent snapshots, local text search and optional auto-refresh that stops when leaving the view. Raw logs stay out of automatic debug reports.

### Fixed

- Launcher source pages remain available for installed and current-default launchers. Projectivy links to its official GitHub releases; opening the page never uninstalls or changes the current launcher (#161).
Expand Down
143 changes: 143 additions & 0 deletions v2/crates/core/src/commands/logcat.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
use serde::Serialize;
use tauri::State;

use crate::adb::driver::{BoundedShellOutput, ShellTermination};
use crate::engine::logcat::{parse_main_pid, LogcatOptions};

use super::{quote_shell_arg, AppState};

#[derive(Debug, Serialize)]
pub struct DeviceLogs {
pub output: BoundedShellOutput,
pub package: Option<String>,
pub pid: Option<u32>,
}

#[tauri::command]
pub async fn read_device_logs(
state: State<'_, AppState>,
serial: String,
options: LogcatOptions,
) -> Result<DeviceLogs, String> {
read_device_logs_impl(state.inner(), &serial, options).await
}

async fn read_device_logs_impl(
state: &AppState,
serial: &str,
options: LogcatOptions,
) -> Result<DeviceLogs, String> {
options.validate()?;
let adb = state.adb_snapshot().await;
let pid = if let Some(package) = &options.package {
let output = adb
.shell_bounded(serial, &format!("pidof {}", quote_shell_arg(package)))
.await
.map_err(|e| format!("Could not resolve the app's main process: {e}"))?;
if output.termination != ShellTermination::Completed || output.exit_code != Some(0) {
return Err("Could not resolve the app's main process. It may be stopped, or this TV may not support pidof. No unfiltered logs were requested.".into());
}
Some(parse_main_pid(&output.stdout)?)
} else {
None
};
let command = options.command(pid)?;
// Bounded execution redacts command output from the app's debug/report logs.
let output = adb
.shell_bounded(serial, &command)
.await
.map_err(|e| format!("Could not read device logs: {e}"))?;
Ok(DeviceLogs {
output,
package: options.package,
pid,
})
}

#[cfg(test)]
mod tests {
use super::*;
use crate::commands::test_support::{state_with, MockAdb};
use crate::engine::logcat::LogPriority;

fn options(package: Option<&str>) -> LogcatOptions {
LogcatOptions {
priority: LogPriority::Warning,
lines: 100,
tag: None,
package: package.map(str::to_string),
}
}

#[tokio::test]
async fn app_capture_resolves_the_main_pid_each_time() {
let mock = MockAdb::default()
.on_shell("pidof", "123\n")
.on_shell("logcat", "one log line\n");
let calls = mock.shell_log();
let result =
read_device_logs_impl(&state_with(mock), "tv", options(Some("com.example.app")))
.await
.unwrap();
assert_eq!(result.pid, Some(123));
assert_eq!(result.output.stdout, "one log line\n");
assert_eq!(
calls.lock().unwrap().as_slice(),
[
"pidof 'com.example.app'",
"logcat -d -v threadtime -t 100 --pid=123 '*:W'"
]
);
}

#[tokio::test]
async fn failed_or_ambiguous_pid_never_reads_all_logs() {
for reply in ["", "1 2", "pidof: not found"] {
let mock = MockAdb::default().on_shell("pidof", reply);
let calls = mock.shell_log();
assert!(read_device_logs_impl(
&state_with(mock),
"tv",
options(Some("com.example.app"))
)
.await
.is_err());
assert_eq!(calls.lock().unwrap().len(), 1);
}
}

#[tokio::test]
async fn unsuccessful_pid_command_never_uses_its_stdout() {
let mock = MockAdb::default().on_shell_exit("pidof", "123", 1);
let calls = mock.shell_log();
assert!(
read_device_logs_impl(&state_with(mock), "tv", options(Some("com.example.app")))
.await
.is_err()
);
assert_eq!(calls.lock().unwrap().len(), 1);
}

#[tokio::test]
async fn invalid_filter_never_touches_the_driver() {
let mock = MockAdb::default();
let calls = mock.shell_log();
assert!(
read_device_logs_impl(&state_with(mock), "tv", options(Some("com.example;id")))
.await
.is_err()
);
assert!(calls.lock().unwrap().is_empty());
}

#[tokio::test]
async fn device_failure_is_not_an_empty_success() {
let mock = MockAdb::default().on_shell_err("logcat", "device offline");
assert!(
read_device_logs_impl(&state_with(mock), "tv", options(None))
.await
.unwrap_err()
.contains("device offline")
);
}
}
1 change: 1 addition & 0 deletions v2/crates/core/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod input;
pub mod launcher;
pub mod license;
pub mod loader;
pub mod logcat;
pub mod optimize;
pub mod reboot;
pub mod recovery;
Expand Down
175 changes: 175 additions & 0 deletions v2/crates/core/src/engine/logcat.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum LogPriority {
Verbose,
Debug,
#[default]
Info,
Warning,
Error,
Fatal,
}

impl LogPriority {
fn flag(self) -> char {
match self {
Self::Verbose => 'V',
Self::Debug => 'D',
Self::Info => 'I',
Self::Warning => 'W',
Self::Error => 'E',
Self::Fatal => 'F',
}
}
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LogcatOptions {
pub priority: LogPriority,
pub lines: u16,
pub tag: Option<String>,
pub package: Option<String>,
}

impl LogcatOptions {
pub fn validate(&self) -> Result<(), String> {
if !(1..=1000).contains(&self.lines) {
return Err("Choose between 1 and 1000 recent log entries.".into());
}
if let Some(tag) = &self.tag {
if tag.is_empty()
|| tag.starts_with('-')
|| tag.len() > 64
|| !tag
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
{
return Err(
"A log tag must be 1–64 letters, digits, dots, underscores or hyphens, and cannot start with a hyphen.".into(),
);
}
}
if let Some(package) = &self.package {
if package.len() > 255 || !super::launcher::is_valid_package_name(package) {
return Err("Choose a valid Android package name.".into());
}
}
Ok(())
}

pub fn command(&self, pid: Option<u32>) -> Result<String, String> {
self.validate()?;
if self.package.is_some() != pid.is_some() || pid == Some(0) {
return Err("An app filter requires exactly one running main-process ID.".into());
}
let mut command = format!("logcat -d -v threadtime -t {}", self.lines);
if let Some(pid) = pid {
command.push_str(&format!(" --pid={pid}"));
}
match &self.tag {
Some(tag) => command.push_str(&format!(" '{tag}:{}' '*:S'", self.priority.flag())),
None => command.push_str(&format!(" '*:{}'", self.priority.flag())),
}
Ok(command)
}
}

pub fn parse_main_pid(text: &str) -> Result<u32, String> {
let words: Vec<_> = text.split_whitespace().collect();
if words.len() != 1 {
return Err(
"The app has no single running main process. Start it on the TV, then retry.".into(),
);
}
let value = words[0];
if !value.chars().all(|c| c.is_ascii_digit()) {
return Err("The TV did not return a valid process ID.".into());
}
value
.parse::<u32>()
.ok()
.filter(|pid| *pid > 0)
.ok_or_else(|| "The TV did not return a valid process ID.".into())
}

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

fn options() -> LogcatOptions {
LogcatOptions {
priority: LogPriority::Info,
lines: 200,
tag: None,
package: None,
}
}

#[test]
fn snapshots_are_finite_and_never_clear_or_change_logging() {
assert_eq!(
options().command(None).unwrap(),
"logcat -d -v threadtime -t 200 '*:I'"
);
}

#[test]
fn a_tag_is_an_allowlist_not_an_additional_global_filter() {
let mut o = options();
o.tag = Some("ActivityManager".into());
o.priority = LogPriority::Warning;
assert_eq!(
o.command(None).unwrap(),
"logcat -d -v threadtime -t 200 'ActivityManager:W' '*:S'"
);
}

#[test]
fn unresolved_app_filter_never_falls_back_to_all_apps() {
let mut o = options();
o.package = Some("com.example.app".into());
assert!(o.command(None).is_err());
assert!(o.command(Some(0)).is_err());
assert!(o.command(Some(123)).unwrap().contains(" --pid=123 "));
assert!(options().command(Some(123)).is_err());
}

#[test]
fn filters_cannot_inject_shell_words_or_logcat_flags() {
for bad in [
"", "*:V", "x y", "x';id", "$(id)", "x\ny", "-c", "--clear", "-G",
] {
let mut o = options();
o.tag = Some(bad.into());
assert!(o.validate().is_err(), "{bad:?}");
}
for bad in ["", "com.example;id", "com.example app", "--pid=1", "$(id)"] {
let mut o = options();
o.package = Some(bad.into());
assert!(o.validate().is_err(), "{bad:?}");
}
for lines in [0, 1001, u16::MAX] {
let mut o = options();
o.lines = lines;
assert!(o.validate().is_err());
}
}

#[test]
fn process_resolution_is_strict() {
assert_eq!(parse_main_pid("123\n").unwrap(), 123);
for bad in [
"",
"0",
"1 2",
"+123",
"1;id",
"pidof: not found",
"4294967296",
] {
assert!(parse_main_pid(bad).is_err(), "{bad:?}");
}
}
}
1 change: 1 addition & 0 deletions v2/crates/core/src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod app_lists;
pub mod detection;
pub mod diagnostics;
pub mod launcher;
pub mod logcat;
pub mod media;
pub mod optimize;
pub mod safety;
Expand Down
3 changes: 2 additions & 1 deletion v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
"e2e:replay": "node e2e/replay.mjs",
"screenshots": "node screenshots/capture.mjs && bash screenshots/build-gif.sh",
"tauri": "tauri",
"subset-icons": "python3 scripts/subset-material-symbols.py"
"subset-icons": "python3 scripts/subset-material-symbols.py",
"test:device-logs": "node tests/log-poller.mjs && node tests/device-logs.mjs"
},
"license": "UNLICENSED",
"dependencies": {
Expand Down
Loading
Loading