From ab8148b457820e3c96d188f307a3813640efe67d Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Thu, 8 Oct 2026 16:45:46 -0500 Subject: [PATCH 1/5] Add bounded read-only device log snapshots and refresh controls --- .github/workflows/v2-tests.yml | 4 + docs/DEVICE-LOGS-2026-10-08.md | 28 ++++ v2/CHANGELOG.md | 2 + v2/crates/core/src/commands/logcat.rs | 143 ++++++++++++++++ v2/crates/core/src/commands/mod.rs | 1 + v2/crates/core/src/engine/logcat.rs | 175 ++++++++++++++++++++ v2/crates/core/src/engine/mod.rs | 1 + v2/package.json | 3 +- v2/src-tauri/src/adb/driver.rs | 16 +- v2/src-tauri/src/lib.rs | 5 +- v2/src/lib/api.ts | 4 + v2/src/lib/components/LogsTab.svelte | 102 ++++++++++++ v2/src/lib/demo-mock.ts | 11 ++ v2/src/lib/log-poller.ts | 75 +++++++++ v2/src/lib/types.ts | 18 ++ v2/src/routes/devices/[serial]/+page.svelte | 9 +- v2/tests/device-logs.mjs | 108 ++++++++++++ v2/tests/log-poller.mjs | 72 ++++++++ 18 files changed, 765 insertions(+), 12 deletions(-) create mode 100644 docs/DEVICE-LOGS-2026-10-08.md create mode 100644 v2/crates/core/src/commands/logcat.rs create mode 100644 v2/crates/core/src/engine/logcat.rs create mode 100644 v2/src/lib/components/LogsTab.svelte create mode 100644 v2/src/lib/log-poller.ts create mode 100644 v2/tests/device-logs.mjs create mode 100644 v2/tests/log-poller.mjs diff --git a/.github/workflows/v2-tests.yml b/.github/workflows/v2-tests.yml index 409c78d3..4c863412 100644 --- a/.github/workflows/v2-tests.yml +++ b/.github/workflows/v2-tests.yml @@ -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 diff --git a/docs/DEVICE-LOGS-2026-10-08.md b/docs/DEVICE-LOGS-2026-10-08.md new file mode 100644 index 00000000..8bd3f25b --- /dev/null +++ b/docs/DEVICE-LOGS-2026-10-08.md @@ -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. diff --git a/v2/CHANGELOG.md b/v2/CHANGELOG.md index 566f7614..51af3c83 100644 --- a/v2/CHANGELOG.md +++ b/v2/CHANGELOG.md @@ -22,6 +22,8 @@ this file and shows the newest few sections, dated, from its version button. ## Unreleased +- 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. + ## v2-2.3.1 — 2026-10-04 Fixes for Google TV launcher switching (#122, #157, #158): Setup Wraith, Google TV's setup diff --git a/v2/crates/core/src/commands/logcat.rs b/v2/crates/core/src/commands/logcat.rs new file mode 100644 index 00000000..fd1f1842 --- /dev/null +++ b/v2/crates/core/src/commands/logcat.rs @@ -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, + pub pid: Option, +} + +#[tauri::command] +pub async fn read_device_logs( + state: State<'_, AppState>, + serial: String, + options: LogcatOptions, +) -> Result { + read_device_logs_impl(state.inner(), &serial, options).await +} + +async fn read_device_logs_impl( + state: &AppState, + serial: &str, + options: LogcatOptions, +) -> Result { + 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") + ); + } +} diff --git a/v2/crates/core/src/commands/mod.rs b/v2/crates/core/src/commands/mod.rs index d0048209..eb068ac8 100644 --- a/v2/crates/core/src/commands/mod.rs +++ b/v2/crates/core/src/commands/mod.rs @@ -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; diff --git a/v2/crates/core/src/engine/logcat.rs b/v2/crates/core/src/engine/logcat.rs new file mode 100644 index 00000000..e8b26ddd --- /dev/null +++ b/v2/crates/core/src/engine/logcat.rs @@ -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, + pub package: Option, +} + +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) -> Result { + 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 { + 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::() + .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:?}"); + } + } +} diff --git a/v2/crates/core/src/engine/mod.rs b/v2/crates/core/src/engine/mod.rs index 7263a391..bff722dc 100644 --- a/v2/crates/core/src/engine/mod.rs +++ b/v2/crates/core/src/engine/mod.rs @@ -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; diff --git a/v2/package.json b/v2/package.json index b8a8acd4..bf8119f8 100644 --- a/v2/package.json +++ b/v2/package.json @@ -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": { diff --git a/v2/src-tauri/src/adb/driver.rs b/v2/src-tauri/src/adb/driver.rs index 7a53afcc..58e10d45 100644 --- a/v2/src-tauri/src/adb/driver.rs +++ b/v2/src-tauri/src/adb/driver.rs @@ -302,8 +302,8 @@ fn adb_session_line( }) } -/// The expert shell runs whatever the user typed, so neither the command nor -/// its output is recorded — only that it ran and how it ended. +/// Expert shell commands and device logs can contain private data. Record +/// only that bounded execution ran and how it ended, never its payload. fn bounded_session_line( serial: &str, ms: u128, @@ -318,7 +318,7 @@ fn bounded_session_line( "kind": "adb", "ts": session::now_ts(), "serial": serial, - "args": ["-s", serial, "shell", ""], + "args": ["-s", serial, "shell", ""], "ms": ms, "exit_code": exit_code, "stdout": "", @@ -432,12 +432,12 @@ impl AdbDriver for SubprocessAdb { )); } match &result { - // Only the expert shell uses this path. Its command and output - // are whatever the user chose to run, so neither is logged: the + // Expert shell and logcat use this path. Their commands and output + // may contain private data, so neither is logged: the // debug log feeds the bug-report bundle. Ok(out) => debug!( serial, - command = "", + command = "", ms = started.elapsed().as_millis(), exit_code = ?out.exit_code, termination = ?out.termination, @@ -447,7 +447,7 @@ impl AdbDriver for SubprocessAdb { ), Err(e) => debug!( serial, - command = "", + command = "", ms = started.elapsed().as_millis(), error = %e, "adb shell (bounded) failed" @@ -860,7 +860,7 @@ mod tests { let line = bounded_session_line("tv:5555", 9, &result); let text = line.to_string(); assert!(!text.contains("secret")); - assert_eq!(line["args"][3], ""); + assert_eq!(line["args"][3], ""); assert_eq!(line["exit_code"], 0); assert_eq!(line["termination"], "completed"); assert_eq!(line["stream"], "bounded"); diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index d330b72a..5366ca64 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -20,8 +20,8 @@ use adb::SubprocessAdb; use commands::{app_report, backup, diagnostics, files, install, scan, sideload, update, AppState}; use shield_optimizer_core::adb::{AdbDriver, AdbError, AdbOutput, AdbResult}; use shield_optimizer_core::commands::{ - apps, devices, health, input, launcher, loader, optimize, reboot, recovery, screenshot, shell, - snapshot, tuning, + apps, devices, health, input, launcher, loader, logcat, optimize, reboot, recovery, screenshot, + shell, snapshot, tuning, }; use shield_optimizer_core::license::Entitlement; @@ -145,6 +145,7 @@ pub fn invoke_handler( recovery::panic_recovery, reboot::reboot_device, shell::run_shell, + logcat::read_device_logs, tuning::get_tweaks, tuning::write_setting, tuning::set_display_scaling, diff --git a/v2/src/lib/api.ts b/v2/src/lib/api.ts index 3e90993f..89cfbed9 100644 --- a/v2/src/lib/api.ts +++ b/v2/src/lib/api.ts @@ -19,6 +19,8 @@ import type { CurrentLauncher, Device, DeviceReport, + DeviceLogOptions, + DeviceLogResult, DeviceType, DiscoveredApk, DisplayScalePreset, @@ -59,6 +61,8 @@ import type { } from "./types"; export const api = { + readDeviceLogs: (serial: string, options: DeviceLogOptions) => + invoke("read_device_logs", { serial, options }), adbStatus: () => invoke("adb_status"), checkForUpdate: () => invoke("check_for_update"), installAdb: () => invoke("install_adb"), diff --git a/v2/src/lib/components/LogsTab.svelte b/v2/src/lib/components/LogsTab.svelte new file mode 100644 index 00000000..6981f1e5 --- /dev/null +++ b/v2/src/lib/components/LogsTab.svelte @@ -0,0 +1,102 @@ + + + +
+
+
+

Device logs

+

Recent logcat snapshots, read only

+
+
+

+ Logs can contain account details, URLs and tokens. Review them before sharing. + This view does not save logs or add them to bug reports. +

+
+ + + + +
+

App filtering covers only its currently running main process, not helper processes. Android can reuse process IDs, so older entries may belong to a previous process. Filters and logcat options depend on the TV's Android version.

+
+ + {#if pollState.running} + + {:else} + + {/if} + {pollState.busy ? "Reading…" : pollState.running ? "Waiting for next snapshot…" : "Stopped"} +
+

Refreshes 3 seconds after each read finishes. Each read is limited to 30 seconds and 256 KiB per output stream; resolving an app adds a separate bounded read. Stop prevents further refreshes; an in-flight read finishes within its limits. Leaving this tab or hiding the app stops refreshes.

+ {#if pollState.error}{/if} + {#if pollState.value} +

+ Snapshot at {pollState.value.at} · {pollState.value.options.priority} and above · + {pollState.value.options.tag ? `tag ${pollState.value.options.tag}` : "all tags"} · + {pollState.value.data.package ? `${pollState.value.data.package} (PID ${pollState.value.data.pid})` : "all processes"} +

+ {#if pollState.value.data.output.termination !== "completed"} + + {/if} + {#if pollState.value.data.output.stderr}
{pollState.value.data.output.stderr}
{/if} + +
{displayed || (text ? "No displayed lines match your search." : "No entries matched these filters in the recent log buffer.")}
+ {/if} +
+ + diff --git a/v2/src/lib/demo-mock.ts b/v2/src/lib/demo-mock.ts index 7a0d870e..20d0c273 100644 --- a/v2/src/lib/demo-mock.ts +++ b/v2/src/lib/demo-mock.ts @@ -703,6 +703,17 @@ function handle(cmd: string, args: Record): unknown { interfaces: [{ name: "eth0", rx_bytes_per_s: 11_534_336, tx_bytes_per_s: 204_800 }], interval_ms: 1000, }; + case "read_device_logs": { + const options = args.options as { package?: string; tag?: string }; + return { + package: options.package ?? null, + pid: options.package ? 1234 : null, + output: { + stdout: "10-08 12:00:00.000 1234 1234 I " + (options.tag || "ActivityManager") + ": Synthetic demo log entry\n", + stderr: "", exit_code: 0, termination: "completed", + }, + }; + } case "run_shell": { const command = String(args.command ?? ""); // Mirror the real safety gate so the demo/screenshot layer cannot show diff --git a/v2/src/lib/log-poller.ts b/v2/src/lib/log-poller.ts new file mode 100644 index 00000000..1b43f1b3 --- /dev/null +++ b/v2/src/lib/log-poller.ts @@ -0,0 +1,75 @@ +/** Serial snapshot polling: bounded reads, no overlap, no late results after stop or navigation. */ +export interface LogPollState { + busy: boolean; + running: boolean; + value: T | null; + error: string | null; +} + +export class LogPoller { + private state: LogPollState = { busy: false, running: false, value: null, error: null }; + private generation = 0; + private destroyed = false; + private timer: ReturnType | null = null; + + constructor( + private read: () => Promise, + private context: () => string | null, + private changed: (state: LogPollState) => void, + private delayMs = 3000, + ) {} + + private publish() { + if (!this.destroyed) this.changed({ ...this.state }); + } + + start() { + if (this.destroyed || this.state.busy || this.state.running || this.context() === null) return; + this.state.running = true; + void this.once(); + } + + stop() { + this.state.running = false; + ++this.generation; + if (this.timer !== null) clearTimeout(this.timer); + this.timer = null; + this.publish(); + } + + dispose() { + this.destroyed = true; + this.stop(); + } + + async once() { + if (this.destroyed || this.state.busy) return; + if (this.timer !== null) clearTimeout(this.timer); + this.timer = null; + const context = this.context(); + if (context === null) { this.stop(); return; } + const generation = ++this.generation; + const current = () => !this.destroyed && generation === this.generation && this.context() === context; + this.state.busy = true; + this.state.error = null; + this.state.value = null; + this.publish(); + try { + const value = await this.read(); + if (current()) this.state.value = value; + } catch (error) { + if (current()) { + this.state.error = String(error); + // A failed/unsupported read must not keep retrying the device forever. + this.state.running = false; + } + } finally { + this.state.busy = false; + if (!current()) this.state.running = false; + this.publish(); + if (current() && this.state.running) { + this.timer = setTimeout(() => { this.timer = null; void this.once(); }, this.delayMs); + } + } + } +} diff --git a/v2/src/lib/types.ts b/v2/src/lib/types.ts index 2d5c9954..d17e79ca 100644 --- a/v2/src/lib/types.ts +++ b/v2/src/lib/types.ts @@ -169,6 +169,24 @@ export interface ResourceSample { interval_ms: number | null; } +export interface DeviceLogOptions { + priority: "verbose" | "debug" | "info" | "warning" | "error" | "fatal"; + lines: number; + tag: string | null; + package: string | null; +} + +export interface DeviceLogResult { + output: { + stdout: string; + stderr: string; + exit_code: number | null; + termination: "completed" | "output_limit" | "timeout"; + }; + package: string | null; + pid: number | null; +} + export interface ShellRunResult { stdout: string; stderr: string; diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index e54291ef..8236b34a 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -58,11 +58,12 @@ import RemoteTab from "$lib/components/RemoteTab.svelte"; import OptimizeTab from "$lib/components/OptimizeTab.svelte"; import MediaTab from "$lib/components/MediaTab.svelte"; + import LogsTab from "$lib/components/LogsTab.svelte"; import ShellTab from "$lib/components/ShellTab.svelte"; let serial = $derived(decodeURIComponent($page.params.serial ?? "")); - type Tab = "overview" | "health" | "media" | "launcher" | "apps" | "optimize" | "tweaks" | "remote" | "files" | "snapshot" | "sideload" | "shell"; + type Tab = "overview" | "health" | "media" | "launcher" | "apps" | "optimize" | "tweaks" | "remote" | "files" | "snapshot" | "sideload" | "shell" | "logs"; let activeTab = $state("overview"); let device = $state(null); @@ -2101,6 +2102,7 @@ { id: "remote", label: "Remote", far: false }, { id: "files", label: "Files", far: false }, { id: "media", label: "Playback", far: false }, + { id: "logs", label: "Logs", far: false }, { id: "shell", label: "Shell", far: true }, ] as t (t.id)} {#if pollState.running} @@ -73,7 +73,11 @@ {/if} {pollState.busy ? "Reading…" : pollState.running ? "Waiting for next snapshot…" : "Stopped"} -

Refreshes 3 seconds after each read finishes. Each read is limited to 30 seconds and 256 KiB per output stream; resolving an app adds a separate bounded read. Stop prevents further refreshes; an in-flight read finishes within its limits. Leaving this tab or hiding the app stops refreshes.

+
+ Capture limits and app scope +

Refreshes 3 seconds after each read finishes. Each read is limited to 30 seconds and 256 KiB per output stream; resolving an app adds a separate bounded read. Stop prevents further refreshes; an in-flight read finishes within its limits. Leaving this tab or hiding the app stops refreshes.

+

Android can reuse process IDs, so older entries may belong to a previous process. Filters and logcat options depend on the TV's Android version. Snapshots can overlap or miss high-volume traffic; this is not a lossless recording. Raw logs are not added to automatic bug reports.

+
{#if pollState.error}{/if} {#if pollState.value}

@@ -91,6 +95,12 @@