From 16559c191c1a80c9f89837db8346b973f8350d0d Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Sun, 18 Oct 2026 14:59:31 -0400 Subject: [PATCH 1/5] Fix Quick Sync startup and add automatic support diagnostics --- .github/workflows/release.yml | 8 +- .github/workflows/testflight.yml | 2 + Cargo.lock | 3 + RELEASE_NOTES.md | 11 + SETUP.md | 25 +- docs/host-settings.md | 6 +- host/Cargo.toml | 4 + host/build.rs | 46 ++ host/src/diagnostics.rs | 201 +++++++ host/src/encoder/input.rs | 117 +++- host/src/encoder/mod.rs | 142 +++-- host/src/ffmpeg_log.c | 18 + host/src/gpu.rs | 22 + host/src/gui/page_settings.rs | 9 + host/src/lib.rs | 3 + host/src/logging.rs | 313 +++++++++- host/src/main.rs | 6 + host/src/stats.rs | 1 + host/src/telemetry/mod.rs | 554 ++++++++++++++++++ host/src/transport/session.rs | 5 + host/tests/amf_encoder.rs | 29 +- ios/EternalMonitor/App/ConnectView.swift | 4 + .../App/ConnectionManager.swift | 23 +- ios/EternalMonitor/App/DiagnosticsView.swift | 81 +++ .../App/EternalMonitorApp.swift | 9 + ios/EternalMonitor/App/SettingsView.swift | 12 + .../Core/DiagnosticsUpload.swift | 188 ++++++ .../Core/SupportDiagnostics.swift | 156 +++++ ios/EternalMonitor/Decode/VideoDecoder.swift | 47 +- ios/EternalMonitor/Info.plist | 2 + ios/EternalMonitor/Network/FramedLink.swift | 15 +- ios/EternalMonitor/Network/UDPReceiver.swift | 10 +- ios/EternalMonitor/PrivacyInfo.xcprivacy | 80 ++- .../DiagnosticsUploadTests.swift | 125 ++++ .../SupportDiagnosticsTests.swift | 56 ++ .../ConnectScreenTests.swift | 23 + scripts/testflight-local.sh | 10 +- 37 files changed, 2254 insertions(+), 112 deletions(-) create mode 100644 host/src/diagnostics.rs create mode 100644 host/src/ffmpeg_log.c create mode 100644 host/src/telemetry/mod.rs create mode 100644 ios/EternalMonitor/App/DiagnosticsView.swift create mode 100644 ios/EternalMonitor/Core/DiagnosticsUpload.swift create mode 100644 ios/EternalMonitor/Core/SupportDiagnostics.swift create mode 100644 ios/EternalMonitorTests/DiagnosticsUploadTests.swift create mode 100644 ios/EternalMonitorTests/SupportDiagnosticsTests.swift diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5526deb5..07371eb8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,8 @@ jobs: timeout-minutes: 60 env: LIBCLANG_PATH: C:\Program Files\LLVM\bin + ETERNAL_DIAGNOSTICS_ENDPOINT: ${{ secrets.ETERNAL_DIAGNOSTICS_ENDPOINT }} + ETERNAL_DIAGNOSTICS_INGEST_KEY: ${{ secrets.ETERNAL_DIAGNOSTICS_INGEST_KEY }} steps: - uses: actions/checkout@v4 @@ -89,7 +91,11 @@ jobs: - name: Build the installer shell: pwsh - run: .\scripts\build-installer.ps1 + run: | + if (!$env:ETERNAL_DIAGNOSTICS_ENDPOINT -or !$env:ETERNAL_DIAGNOSTICS_INGEST_KEY) { + throw "Release diagnostics endpoint and ingest credential must be configured." + } + .\scripts\build-installer.ps1 - name: Compute SHA-256 id: hash diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 3f436905..3e9aa691 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -37,6 +37,8 @@ jobs: - name: Archive and upload, or compile when secrets are absent if: github.event_name != 'pull_request' env: + ETERNAL_DIAGNOSTICS_ENDPOINT: ${{ secrets.ETERNAL_DIAGNOSTICS_ENDPOINT }} + ETERNAL_DIAGNOSTICS_INGEST_KEY: ${{ secrets.ETERNAL_DIAGNOSTICS_INGEST_KEY }} ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} diff --git a/Cargo.lock b/Cargo.lock index b39dba20..abd221f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1171,6 +1171,7 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" name = "eternal-host" version = "0.3.0" dependencies = [ + "cc", "eframe", "egui_kittest", "eternal-wire", @@ -1180,8 +1181,10 @@ dependencies = [ "mdns-sd", "once_cell", "parking_lot", + "pkg-config", "plist", "qrcode", + "regex", "semver", "serde", "serde_json", diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bbedd756..2d276f41 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,14 @@ +## EternalMonitor unreleased + +- Windows and iPad can share diagnostics automatically with support, queue events while offline and retry after reconnecting. Automatic sharing can be disabled in Settings. + +- Windows and iPad now retain bounded support logs across launches, including device/build details, encoder/decoder errors and stream summaries. Export iPad reports from Settings → Diagnostics. Windows Copy logs includes native FFmpeg driver errors and removes pairing credentials. + +- Fix Intel Quick Sync startup by converting capture frames to its supported + NV12 input format for H.264 and HEVC. +- Try the other Windows hardware encoders when the preferred encoder cannot + open, before falling back to CPU encoding. Record each failure in the host log. + ## EternalMonitor v0.3.0 v0.3.0 is the current stable release. Install the Windows app with iPad diff --git a/SETUP.md b/SETUP.md index 58c085b9..216ae134 100644 --- a/SETUP.md +++ b/SETUP.md @@ -191,7 +191,7 @@ or in the controls while streaming. | The iPad never connects over USB | Open Apple Devices on the PC and leave it open. Unlock the iPad, accept **Trust**, and try another cable. Then unplug and plug in again. | | The iPad can't find the PC at all | Open the iPad's **Settings** app, then **Privacy & Security**, then **Local Network**, and turn on **EternalMonitor**. | | The picture stutters or freezes on Wi-Fi | Move closer to the router, or use a 5 GHz network. Connect the PC to the router with a network cable, or use USB. | -| **Hardware encoder unavailable** on the PC | Update your graphics driver from NVIDIA, AMD or Intel, then click **Restart**. | +| **Hardware encoder unavailable** on the PC | In **Settings**, select **Auto** for the encoder and **YUV420** for encoder input, then restart the stream. Update the driver from your GPU manufacturer. If this persists, use **Performance** → **Copy logs** and include the iPad report from **Settings → Diagnostics**. Some GPUs have no supported hardware encoder. | | **Extended display unavailable** on the PC | Run EternalMonitor-Setup.exe again to repair the install, then restart the stream. | **Using a VPN such as Tailscale?** In the PC window, open **Settings** and set @@ -207,10 +207,27 @@ In the PC window, open **Performance** and click **Copy logs**. Paste the result into a [bug report on GitHub](https://github.com/sidebandstudio/EternalMonitor/issues/new/choose) or an email to [hello@sideband.studio](mailto:hello@sideband.studio). Say what you tried, what you saw, your iPad model, and whether you used Wi-Fi or -USB. The logs include the current pairing code. Click **New code** in the PC -window after sharing them. Don't post the QR code publicly either, because -anyone who scans it can connect. +USB. On iPad, open **Settings → Diagnostics**, then **Export diagnostics** +for a text file or **Copy diagnostics**. Include both reports when possible. + +Reports include Windows build, CPU, GPU and driver details; iPad hardware, +iPadOS and app build; encoder/decoder setup, connection events and periodic +stream statistics. Each side records its own device information. Matching +session IDs and timestamps help compare the reports without changing the +wire protocol. Logs survive app restarts and rotate automatically. Windows +keeps three files up to 2 MiB each; iPad keeps three up to 1 MiB each. + +Pairing codes and tokens are redacted from saved support logs and exports. +Reports still contain PC/device names and local network addresses. Review +them before posting publicly. Keep the QR code private because anyone who +scans it can connect. Pairing keeps other people on your network from connecting, but the stream itself is not encrypted. Use EternalMonitor on networks you trust, such as your home Wi-Fi. + +### Automatic diagnostics + +Configured releases send device information, errors and stream statistics to EternalMonitor support automatically. Windows Settings and iPad Settings → Diagnostics show the current upload state and an automatic sharing control. Offline events retry after reconnection or relaunch. PC names and network addresses may be included; pairing credentials, screen frames and audio are excluded. Manual exports remain available. + +Uploads use encrypted HTTPS. Support history is retained for up to 30 days and may be removed sooner when storage limits are reached. Disabling sharing clears pending uploads; diagnostics already sent expire under the retention policy. Existing releases require an update to enable uploads. diff --git a/docs/host-settings.md b/docs/host-settings.md index b97b6cf9..96820f8e 100644 --- a/docs/host-settings.md +++ b/docs/host-settings.md @@ -2,13 +2,15 @@ The host offers 30, 60, 90 and 120 fps. An iPad requesting 30, 60 or 120 fps caps the host at the lower of the two settings. Disconnecting clears the client cap without changing the host preference. The iPad's Frame Rate setting applies on the next connection. -Encoder input defaults to YUV420. BGRA and Auto are opt-in until both NVENC and AMF pass the reference PC's color comparisons and ten-minute stability runs. Auto chooses BGRA, then BGR0, only when the encoder advertises support. It otherwise uses YUV420P. libx264 always uses YUV420P. BGRA avoids the host's color conversion step. An unsupported forced BGRA setting reports an encoder error. Changing this setting requires a stream restart; the Settings page offers Restart now. `ETERNAL_INPUT=auto`, `bgra` or `yuv420` overrides the saved setting at launch. +Encoder input defaults to YUV420. BGRA and Auto are opt-in until both NVENC and AMF pass the reference PC's color comparisons and ten-minute stability runs. Auto chooses BGRA, then BGR0, only when the encoder advertises support. It otherwise chooses an advertised 8-bit 4:2:0 format, preferring YUV420P and using NV12 for Intel Quick Sync. The default YUV420 setting follows the same format selection. libx264 always uses YUV420P. BGRA avoids the host's color conversion step. An unsupported forced BGRA setting reports an encoder error. Changing this setting requires a stream restart; the Settings page offers Restart now. `ETERNAL_INPUT=auto`, `bgra` or `yuv420` overrides the saved setting at launch. + +At stream startup the host tries the selected hardware encoder, then the other Windows hardware backends if it cannot open. It uses libx264 only after those attempts fail. Selecting libx264 explicitly keeps CPU encoding. The session log records each failed encoder and the one that opened. The Connected iPad card shows the latest receiver report. Unrecovered loss counts fragments still missing after repair. Repaired fragments have their own percentage. Both percentages use received, repaired and still-missing fragments as the total. A zero latency estimate displays Measuring until a measurement is available. The update check runs in a background thread with a three-second request timeout. It checks GitHub's latest stable release at most once per day, including after a failed check or an application restart. Turn off Check for updates in Settings to opt out. Dismiss hides the current banner for the rest of that host session. The host and iPad versions must match. -Each host launch rotates `eternal-host-session.log` into `.1`, retaining `.2` as well. The Windows headless host handles Ctrl+C and Ctrl+Break through the normal shutdown path. +Each host launch and each 2 MiB of output rotates `eternal-host-session.log` into `.1`, retaining `.2` as well. Copy logs includes these files, startup evidence and the most recent memory log so queued errors are included. Reports expose queue drops and storage failures. Saved support logs and exports redact pairing codes and tokens; the headless console still prints the pairing code for local automation. Startup reports include Windows build, CPU/RAM, all DXGI adapters and driver versions, FFmpeg library versions and compiled encoder availability. Encoder attempts include advertised pixel formats and numeric FFmpeg errors; native FFmpeg warnings and errors enter the same support log. A ten-second summary records session ID, actual codec, capture/encode/send counters, USB state, heartbeat ages and effective frame/packet settings. The Windows headless host handles Ctrl+C and Ctrl+Break through the normal shutdown path. The installer registers UDP and TCP firewall rules for its installed host executable on private and public networks. Uninstall removes those rules. It also grants local users read and execute access to its display tasks. A failed task produces the reinstall guidance and records Windows' exact error in the host log. A task that runs but times out waiting for a display instead asks the user to check the display and driver. diff --git a/host/Cargo.toml b/host/Cargo.toml index c313111d..a7d700b5 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -33,12 +33,15 @@ mdns-sd = "0.13.11" # Shared state parking_lot = "0.12" once_cell = "1" +regex = "1" # Socket options (send-buffer sizing) before handing the socket to tokio socket2 = "0.5" [build-dependencies] winresource = "0.1" +cc = "1" +pkg-config = "0.3" [target.'cfg(target_os = "macos")'.dev-dependencies] egui_kittest = { version = "0.36", features = ["eframe", "wgpu", "snapshot"] } @@ -63,6 +66,7 @@ features = [ "Win32_Graphics_Direct3D11", "Win32_Graphics_Gdi", "Win32_System_Registry", + "Win32_System_SystemInformation", "Win32_System_IO", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", diff --git a/host/build.rs b/host/build.rs index ef0eb162..5ef4486d 100644 --- a/host/build.rs +++ b/host/build.rs @@ -1,4 +1,50 @@ fn main() { + println!("cargo:rerun-if-env-changed=ETERNAL_BUILD_REVISION"); + println!("cargo:rerun-if-changed=src"); + for args in [ + vec!["rev-parse", "--git-path", "HEAD"], + vec!["rev-parse", "--git-path", "refs"], + ] { + if let Ok(output) = std::process::Command::new("git").args(args).output() { + if output.status.success() { + println!( + "cargo:rerun-if-changed={}", + String::from_utf8_lossy(&output.stdout).trim() + ); + } + } + } + let revision = std::env::var("ETERNAL_BUILD_REVISION").unwrap_or_else(|_| { + let hash = std::process::Command::new("git") + .args(["rev-parse", "--short=12", "HEAD"]) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let dirty = std::process::Command::new("git") + .args(["diff", "--quiet", "HEAD"]) + .status() + .is_ok_and(|status| !status.success()); + format!("{hash}{}", if dirty { "+dirty" } else { "" }) + }); + println!("cargo:rustc-env=ETERNAL_BUILD_REVISION={revision}"); + println!("cargo:rerun-if-changed=src/ffmpeg_log.c"); + println!("cargo:rerun-if-env-changed=FFMPEG_DIR"); + let mut native = cc::Build::new(); + native.file("src/ffmpeg_log.c"); + if let Some(root) = std::env::var_os("FFMPEG_DIR") { + native.include(std::path::PathBuf::from(root).join("include")); + } else { + let library = pkg_config::Config::new() + .probe("libavutil") + .expect("libavutil headers"); + for path in library.include_paths { + native.include(path); + } + } + native.compile("eternal_ffmpeg_log"); + if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" { let mut res = winresource::WindowsResource::new(); res.set_icon("assets/icon.ico"); diff --git a/host/src/diagnostics.rs b/host/src/diagnostics.rs new file mode 100644 index 00000000..7983b08f --- /dev/null +++ b/host/src/diagnostics.rs @@ -0,0 +1,201 @@ +//! Low-frequency support evidence. Never collect credentials or user documents. +use std::ffi::CStr; +use std::sync::{mpsc, OnceLock}; +use std::time::Duration; + +use crate::control::SharedControl; +use tracing::info; + +static SYSTEM_REPORT: OnceLock = OnceLock::new(); + +pub fn system_report() -> &'static str { + SYSTEM_REPORT.get_or_init(|| { + let mut lines = vec![format!( + "EternalMonitor host {} revision={} | OS={} arch={} logical_cpus={} | avcodec={} avutil={} | report_schema=1", + env!("CARGO_PKG_VERSION"), env!("ETERNAL_BUILD_REVISION"), std::env::consts::OS, std::env::consts::ARCH, + std::thread::available_parallelism().map_or(0, usize::from), + ffmpeg_next::codec::version(), ffmpeg_next::util::version(), + )]; + #[cfg(windows)] + lines.extend(windows_system_report()); + let encoders = ["h264_nvenc", "h264_amf", "h264_qsv", "hevc_nvenc", "hevc_amf", "hevc_qsv", "h264_videotoolbox", "hevc_videotoolbox", "libx264"]; + lines.push(format!("Compiled encoders: {}", encoders.iter().map(|name| format!("{name}={}", ffmpeg_next::encoder::find_by_name(name).is_some())).collect::>().join(" "))); + lines.join("\n") + }) +} + +pub fn log_system_report() { + for line in system_report().lines() { + info!(details = line, "Support system information"); + } +} + +extern "C" { + fn eternal_install_ffmpeg_logger(sink: unsafe extern "C" fn(i32, *const libc::c_char)); +} + +/// Install once, before encoder threads start. The C shim owns the platform's va_list ABI. +pub fn install_ffmpeg_logging() { + static INSTALL: std::sync::Once = std::sync::Once::new(); + INSTALL.call_once(|| unsafe { eternal_install_ffmpeg_logger(ffmpeg_message) }); +} + +unsafe extern "C" fn ffmpeg_message(level: i32, message: *const libc::c_char) { + // Never unwind through a C callback, including a third-party tracing subscriber. + let _ = std::panic::catch_unwind(|| { + if message.is_null() { + return; + } + let message = unsafe { CStr::from_ptr(message) }.to_string_lossy(); + let message = message.trim(); + if message.is_empty() { + return; + } + if level <= ffmpeg_next::ffi::AV_LOG_ERROR { + tracing::error!(target: "ffmpeg", ffmpeg_level = level, "{message}"); + } else { + tracing::warn!(target: "ffmpeg", ffmpeg_level = level, "{message}"); + } + }); +} + +pub struct Monitor(mpsc::Sender<()>, Option>); + +impl Monitor { + pub fn start(shared: SharedControl) -> Self { + let (stop, receiver) = mpsc::channel(); + let thread = std::thread::spawn(move || { + while receiver.recv_timeout(Duration::from_secs(10)) + == Err(mpsc::RecvTimeoutError::Timeout) + { + log_stream_summary(&shared); + } + log_stream_summary(&shared); + }); + Self(stop, Some(thread)) + } +} + +impl Drop for Monitor { + fn drop(&mut self) { + let _ = self.0.send(()); + if let Some(thread) = self.1.take() { + let _ = thread.join(); + } + } +} + +fn log_stream_summary(shared: &SharedControl) { + use std::sync::atomic::Ordering::Relaxed; + // Don't keep pipeline locks held while formatting/writing tracing events. + let session_id = shared.session.lock().session_id(); + let sample = crate::stats::PIPELINE_STATS.lock().clone(); + let now_ms = crate::clock::host_now_us() / 1000; + info!(session_id = ?session_id, running = sample.pipeline_running, + captured = sample.capture_frame_count, encoded = sample.encode_frame_count, + sent = sample.transport_packets_sent, capture_fps = sample.capture_fps, + encode_fps = sample.encode_fps, send_fps = sample.transport_fps, + encode_us = sample.encode_time_us, codec = %sample.codec_name, + cpu_fallback = sample.using_software_fallback, + resolution = ?sample.capture_resolution, bitrate = sample.bitrate_bps, + target_fps = shared.effective_fps(), max_dgram = shared.max_dgram.load(Relaxed), + input = ?*shared.encoder_input.lock(), hevc = shared.hevc_enabled.load(Relaxed), + retransmits = sample.transport_retransmits, usb_drops = sample.usb_frames_dropped, + usb_state = %sample.usb_link_state, usb_devices = sample.usb_devices, + capture_heartbeat_age_ms = now_ms.saturating_sub(shared.hb_capture_frame_ms.load(Relaxed)), + encode_heartbeat_age_ms = now_ms.saturating_sub(shared.hb_encode_frame_ms.load(Relaxed)), + "Support stream summary"); +} + +#[cfg(windows)] +fn windows_system_report() -> Vec { + use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX}; + let os_key = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"; + let cpu_key = "HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0"; + let mut lines = vec![format!( + // ProductName can retain "Windows 10" on Windows 11. Preserve its source + // instead of presenting that legacy registry label as the detected OS. + "Windows registry_product={} installation_type={} display_version={} build={}.{}", + registry_string(os_key, "ProductName"), + registry_string(os_key, "InstallationType"), + registry_string(os_key, "DisplayVersion"), + registry_string(os_key, "CurrentBuildNumber"), + registry_dword(os_key, "UBR") + )]; + lines.push(format!( + "CPU={} identifier={}", + registry_string(cpu_key, "ProcessorNameString"), + registry_string(cpu_key, "Identifier") + )); + let mut memory = MEMORYSTATUSEX { + dwLength: std::mem::size_of::() as u32, + ..Default::default() + }; + match unsafe { GlobalMemoryStatusEx(&mut memory) } { + Ok(()) => lines.push(format!( + "RAM total_mb={} available_mb={} load_percent={}", + memory.ullTotalPhys / 1048576, + memory.ullAvailPhys / 1048576, + memory.dwMemoryLoad + )), + Err(error) => lines.push(format!("RAM query failed: {error}")), + } + lines +} + +#[cfg(windows)] +fn registry_string(key: &str, name: &str) -> String { + use windows::{ + core::PCWSTR, + Win32::System::Registry::{RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ}, + }; + let key: Vec = key.encode_utf16().chain(Some(0)).collect(); + let name: Vec = name.encode_utf16().chain(Some(0)).collect(); + let mut value = [0u16; 512]; + let mut bytes = std::mem::size_of_val(&value) as u32; + let result = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + PCWSTR(key.as_ptr()), + PCWSTR(name.as_ptr()), + RRF_RT_REG_SZ, + None, + Some(value.as_mut_ptr().cast()), + Some(&mut bytes), + ) + }; + if result.is_err() { + return format!("unavailable({})", result.0); + } + String::from_utf16_lossy(&value[..value.iter().position(|c| *c == 0).unwrap_or(value.len())]) + .trim() + .to_string() +} + +#[cfg(windows)] +fn registry_dword(key: &str, name: &str) -> String { + use windows::{ + core::PCWSTR, + Win32::System::Registry::{RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_DWORD}, + }; + let key: Vec = key.encode_utf16().chain(Some(0)).collect(); + let name: Vec = name.encode_utf16().chain(Some(0)).collect(); + let mut value = 0u32; + let mut bytes = 4; + let result = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + PCWSTR(key.as_ptr()), + PCWSTR(name.as_ptr()), + RRF_RT_REG_DWORD, + None, + Some((&mut value as *mut u32).cast()), + Some(&mut bytes), + ) + }; + if result.is_err() { + format!("unavailable({})", result.0) + } else { + value.to_string() + } +} diff --git a/host/src/encoder/input.rs b/host/src/encoder/input.rs index 318c1df4..e4e498e1 100644 --- a/host/src/encoder/input.rs +++ b/host/src/encoder/input.rs @@ -27,19 +27,50 @@ pub(super) fn select_input( codec_name: &str, supported: &[Pixel], ) -> Result { - if mode == EncoderInput::Yuv420 || codec_name == "libx264" { + if codec_name == "libx264" { return Ok(Pixel::YUV420P); } - for format in [Pixel::BGRA, Pixel::BGRZ] { + if mode != EncoderInput::Yuv420 { + for format in [Pixel::BGRA, Pixel::BGRZ] { + if supported.contains(&format) { + return Ok(format); + } + } + } + if mode == EncoderInput::Bgra { + return Err("The selected encoder does not accept BGRA/BGR0 input"); + } + // YUV420P and NV12 are both 8-bit 4:2:0. Quick Sync accepts NV12, + // but not planar YUV420P, in the pinned FFmpeg 7.1 SDK. + for format in [Pixel::YUV420P, Pixel::NV12] { if supported.contains(&format) { return Ok(format); } } - if mode == EncoderInput::Bgra { - Err("The selected encoder does not accept BGRA/BGR0 input") - } else { - Ok(Pixel::YUV420P) + if supported.is_empty() { + return Ok(Pixel::YUV420P); } + Err("The selected encoder does not accept 8-bit YUV420P/NV12 input") +} + +pub(super) fn conversion_context( + format: Pixel, + width: u32, + height: u32, +) -> Result, ffmpeg_next::Error> { + if !matches!(format, Pixel::YUV420P | Pixel::NV12) { + return Ok(None); + } + ffmpeg_next::software::scaling::Context::get( + Pixel::BGRA, + width, + height, + format, + width, + height, + ffmpeg_next::software::scaling::Flags::FAST_BILINEAR, + ) + .map(Some) } #[cfg(test)] @@ -58,7 +89,7 @@ mod tests { ); assert_eq!( select_input(EncoderInput::Auto, "h264_qsv", &[Pixel::NV12]), - Ok(Pixel::YUV420P) + Ok(Pixel::NV12) ); assert_eq!( select_input(EncoderInput::Auto, "unknown", &[]), @@ -76,4 +107,76 @@ mod tests { assert_eq!(EncoderInput::from_env("BGRA"), Some(EncoderInput::Bgra)); assert_eq!(EncoderInput::from_env("mistyped"), None); } + + #[test] + fn quick_sync_uses_nv12_for_default_and_auto_input() { + for name in ["h264_qsv", "hevc_qsv"] { + for mode in [EncoderInput::default(), EncoderInput::Auto] { + assert_eq!( + select_input(mode, name, &[Pixel::NV12, Pixel::QSV]), + Ok(Pixel::NV12) + ); + } + assert!(select_input(EncoderInput::Bgra, name, &[Pixel::NV12]).is_err()); + } + assert!(select_input(EncoderInput::Yuv420, "unknown", &[Pixel::QSV]).is_err()); + } + + #[test] + fn nv12_conversion_preserves_luma_and_interleaves_chroma() { + ffmpeg_next::init().unwrap(); + let (width, height) = (96, 64); + let mut bgra = ffmpeg_next::frame::Video::new(Pixel::BGRA, width, height); + let stride = bgra.stride(0); + for y in 0..height as usize { + for x in 0..width as usize { + let offset = y * stride + x * 4; + bgra.data_mut(0)[offset..offset + 4].copy_from_slice(&[ + (x * 2) as u8, + (y * 3) as u8, + (x + y) as u8, + 255, + ]); + } + } + let mut planar = ffmpeg_next::frame::Video::new(Pixel::YUV420P, width, height); + let mut nv12 = ffmpeg_next::frame::Video::new(Pixel::NV12, width, height); + for output in [&mut planar, &mut nv12] { + conversion_context(output.format(), width, height) + .unwrap() + .unwrap() + .run(&bgra, output) + .unwrap(); + } + for y in 0..height as usize { + for x in 0..width as usize { + assert_eq!( + nv12.data(0)[y * nv12.stride(0) + x], + planar.data(0)[y * planar.stride(0) + x] + ); + } + } + for y in 0..height as usize / 2 { + for x in 0..width as usize / 2 { + // swscale's planar and interleaved SIMD paths can round a + // chroma sample differently by one on x86. + assert!( + nv12.data(1)[y * nv12.stride(1) + 2 * x] + .abs_diff(planar.data(1)[y * planar.stride(1) + x]) + <= 1 + ); + assert!( + nv12.data(1)[y * nv12.stride(1) + 2 * x + 1] + .abs_diff(planar.data(2)[y * planar.stride(2) + x]) + <= 1 + ); + } + } + assert!(conversion_context(Pixel::BGRA, width, height) + .unwrap() + .is_none()); + assert!(conversion_context(Pixel::BGRZ, width, height) + .unwrap() + .is_none()); + } } diff --git a/host/src/encoder/mod.rs b/host/src/encoder/mod.rs index 47e93fd6..8eacb4eb 100644 --- a/host/src/encoder/mod.rs +++ b/host/src/encoder/mod.rs @@ -70,7 +70,7 @@ fn run_encode_loop( ) -> Result<(), Box> { // Apply GUI encoder override at pipeline start. The override is consulted only here; // mid-stream changes don't take effect until the user requests a Restart. - let (mut encoder_name, mut codec_display_name) = { + let (mut encoder_name, codec_display_name) = { let override_name = shared.encoder_override.lock().clone(); match override_name { Some(name) if ffmpeg_next::encoder::find_by_name(&name).is_some() => { @@ -247,30 +247,26 @@ fn run_encode_loop( continue; } Err(e) if encoder_name != "libx264" => { - // A hardware encoder (commonly AMF) can fail to *open* even though it is - // compiled into FFmpeg — driver hiccup, AMF runtime busy/missing, transient - // device loss. Fall back to software once so streaming keeps a picture - // instead of the whole encode thread dying. warn!( encoder = %encoder_name, error = %e, - "Encoder failed to open — falling back to libx264 (software)" + "Preferred encoder failed to open; trying remaining encoders" ); - encoder_name = "libx264".to_string(); - codec_display_name = "H.264 (x264)".to_string(); - { - let mut stats = PIPELINE_STATS.lock(); - stats.set_codec_name(&codec_display_name); - stats.set_software_fallback(true); - } - encoder_state = Some(EncoderState::new( - &encoder_name, - raw_frame.width, - raw_frame.height, - desired_bitrate, - target_fps, - input_mode, - )?); + let (name, state) = open_remaining_encoders(&encoder_name, |name| { + EncoderState::new( + name, + raw_frame.width, + raw_frame.height, + desired_bitrate, + target_fps, + input_mode, + ) + })?; + encoder_name = name.to_string(); + encoder_state = Some(state); + PIPELINE_STATS + .lock() + .set_software_fallback(encoder_name == "libx264"); note_encoder_opened(&shared, &encoder_name); } Err(e) => return Err(e), @@ -609,6 +605,8 @@ impl EncoderState { target_fps: u32, input_mode: EncoderInput, ) -> Result> { + info!(encoder = encoder_name, width, height, bitrate_bps, target_fps, + input = ?input_mode, "Opening encoder session"); let legacy_pts = std::env::var("ETERNAL_LEGACY_PTS").is_ok_and(|v| v.trim() == "1"); let codec = ffmpeg_next::encoder::find_by_name(encoder_name) .ok_or_else(|| format!("{} codec not found in FFmpeg", encoder_name))?; @@ -623,6 +621,7 @@ impl EncoderState { } formats }; + info!(encoder = encoder_name, advertised_formats = ?supported, input = ?input_mode, "Encoder advertised input formats"); let pixel_format = select_input(input_mode, encoder_name, &supported)?; let context = ffmpeg_next::codec::Context::new_with_codec(codec); let mut encoder = context.encoder().video()?; @@ -654,7 +653,10 @@ impl EncoderState { let opts = encoder_options(encoder_name); - let encoder = encoder.open_with(opts)?; + let encoder = encoder.open_with(opts).map_err(|error| { + let code: i32 = error.into(); + format!("{encoder_name} open failed: FFmpeg code={code} error={error}; {width}x{height} input={pixel_format:?} bitrate={bitrate_bps} fps={target_fps}") + })?; log_encoder_configuration(encoder_name, &encoder); info!( width, @@ -668,22 +670,10 @@ impl EncoderState { let mut h264 = H264BitstreamState::default(); h264.refresh_parameter_sets_from_extradata(encoder_extradata(&encoder)); - let scaler = if pixel_format == ffmpeg_next::format::Pixel::YUV420P { - Some(ffmpeg_next::software::scaling::Context::get( - ffmpeg_next::format::Pixel::BGRA, - width, - height, - ffmpeg_next::format::Pixel::YUV420P, - width, - height, - // 1:1 scale — the "filter" only converts colorspace, so take the fast path. - ffmpeg_next::software::scaling::Flags::FAST_BILINEAR, - )?) - } else { - None - }; + let scaler = input::conversion_context(pixel_format, width, height)?; + let needs_conversion = scaler.is_some(); if scaler.is_some() { - info!("swscale BGRA->YUV420P context created"); + info!(output = ?pixel_format, "swscale BGRA conversion context created"); } Ok(Self { @@ -704,7 +694,7 @@ impl EncoderState { }, encoder, scaler, - bgra_frame: (pixel_format == ffmpeg_next::format::Pixel::YUV420P).then(|| { + bgra_frame: needs_conversion.then(|| { ffmpeg_next::frame::Video::new(ffmpeg_next::format::Pixel::BGRA, width, height) }), frame: ffmpeg_next::frame::Video::new(pixel_format, width, height), @@ -739,6 +729,31 @@ impl EncoderState { } } +/// A compiled-in encoder may have no usable device or driver. Try each other +/// Windows backend once before using the CPU. Explicit software selection +/// never enters this path, and non-Windows backends keep their own fallback. +fn open_remaining_encoders( + failed: &str, + mut open: impl FnMut(&str) -> Result>, +) -> Result<(&'static str, T), Box> { + let hardware: &[&str] = if matches!(failed, "h264_nvenc" | "h264_amf" | "h264_qsv") { + &["h264_nvenc", "h264_amf", "h264_qsv"] + } else { + &[] + }; + for &name in hardware { + if name == failed { + continue; + } + match open(name) { + Ok(state) => return Ok((name, state)), + Err(error) => warn!(encoder = name, %error, "Hardware encoder did not open"), + } + } + warn!("No hardware encoder opened; falling back to libx264 (software)"); + open("libx264").map(|state| ("libx264", state)) +} + /// The H.265 sibling of an H.264 encoder name, if one exists. pub fn hevc_variant_of(h264_name: &str) -> Option<&'static str> { match h264_name { @@ -1368,6 +1383,57 @@ fn find_ffmpeg_exe() -> Option { mod tests { use super::*; + #[test] + fn startup_tries_other_hardware_before_software() { + let mut attempts = Vec::new(); + let (selected, ()) = open_remaining_encoders("h264_nvenc", |name| { + attempts.push(name.to_string()); + match name { + "h264_qsv" => Ok(()), + _ => Err("device unavailable".into()), + } + }) + .unwrap(); + assert_eq!(attempts, ["h264_amf", "h264_qsv"]); + assert_eq!(selected, "h264_qsv"); + } + + #[test] + fn startup_uses_software_only_after_all_other_hardware_fails() { + for failed in ["h264_nvenc", "h264_amf", "h264_qsv"] { + let mut attempts = Vec::new(); + let (selected, ()) = open_remaining_encoders(failed, |name| { + attempts.push(name.to_string()); + if name == "libx264" { + Ok(()) + } else { + Err("device unavailable".into()) + } + }) + .unwrap(); + assert_eq!(selected, "libx264"); + assert_eq!(attempts.len(), 3); + assert!(!attempts.iter().any(|name| name == failed)); + assert_eq!(attempts.last().unwrap(), "libx264"); + } + assert!(open_remaining_encoders::<()>("h264_amf", |_| { + Err("no encoder can open".into()) + }) + .is_err()); + } + + #[test] + fn non_windows_backends_keep_their_software_fallback() { + let mut attempts = Vec::new(); + let (selected, ()) = open_remaining_encoders("h264_videotoolbox", |name| { + attempts.push(name.to_string()); + Ok(()) + }) + .unwrap(); + assert_eq!(selected, "libx264"); + assert_eq!(attempts, ["libx264"]); + } + #[test] fn software_bitrate_changes_preserve_decoding_and_reduce_packet_size() { ffmpeg_next::init().unwrap(); diff --git a/host/src/ffmpeg_log.c b/host/src/ffmpeg_log.c new file mode 100644 index 00000000..75f7dabc --- /dev/null +++ b/host/src/ffmpeg_log.c @@ -0,0 +1,18 @@ +#include + +/* Keep va_list in C: its ABI differs between MSVC, Intel and Apple ARM. */ +static void (*log_sink)(int, const char *); + +static void forward_log(void *context, int level, const char *format, va_list args) { + if (level > AV_LOG_WARNING || !log_sink) return; + char line[4096]; + int prefix = 1; + int length = av_log_format_line2(context, level, format, args, line, sizeof(line), &prefix); + if (length >= 0) log_sink(level, line); +} + +void eternal_install_ffmpeg_logger(void (*sink)(int, const char *)) { + log_sink = sink; + av_log_set_level(AV_LOG_WARNING); + av_log_set_callback(forward_log); +} diff --git a/host/src/gpu.rs b/host/src/gpu.rs index 488646f2..7892d2e5 100644 --- a/host/src/gpu.rs +++ b/host/src/gpu.rs @@ -1,4 +1,5 @@ use tracing::{info, warn}; +use windows::core::Interface; use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1}; const VENDOR_NVIDIA: u32 = 0x10DE; @@ -103,12 +104,33 @@ fn detect_from_dxgi() -> Result> { ); let vendor = GpuVendor::from_vendor_id(desc.VendorId); let vram_mb = desc.DedicatedVideoMemory as u64 / (1024 * 1024); + let driver_version = match unsafe { + adapter.CheckInterfaceSupport(&windows::Win32::Graphics::Dxgi::IDXGIDevice::IID) + } { + Ok(version) => { + let version = version as u64; + format!( + "{}.{}.{}.{}", + version >> 48, + (version >> 32) & 0xffff, + (version >> 16) & 0xffff, + version & 0xffff + ) + } + Err(error) => format!("unavailable: {error}"), + }; info!( adapter_index = index, name = %name, vendor = %vendor, vram_mb, + vendor_id = desc.VendorId, + device_id = desc.DeviceId, + subsystem_id = desc.SubSysId, + revision = desc.Revision, + shared_memory_mb = desc.SharedSystemMemory / (1024 * 1024), + driver_version, "Found GPU adapter" ); diff --git a/host/src/gui/page_settings.rs b/host/src/gui/page_settings.rs index 8fdf89a7..b36e7303 100644 --- a/host/src/gui/page_settings.rs +++ b/host/src/gui/page_settings.rs @@ -286,6 +286,15 @@ impl AnalyzerApp { }); ui.add_space(GAP - 6.0); + widgets::section_label(ui, "Support diagnostics"); + widgets::list_card().show(ui, |ui| { + let mut upload = crate::telemetry::enabled(); + widgets::setting_row(ui, "Automatically share diagnostics", "Sends device details, errors and stream statistics to EternalMonitor support. Includes PC names and network addresses. Screen content, audio and pairing credentials are excluded.", 64.0, |ui| { widgets::toggle(ui, &mut upload, "Automatically share diagnostics"); }); + if upload != crate::telemetry::enabled() { crate::telemetry::set_enabled(upload); } + ui.label(muted(crate::telemetry::status())); + }); + ui.add_space(GAP - 6.0); + // ── About ─────────────────────────────────────────────────────────── widgets::section_label(ui, "About"); widgets::card().show(ui, |ui| { diff --git a/host/src/lib.rs b/host/src/lib.rs index acdf9d8f..45e67681 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -6,6 +6,7 @@ pub mod autostart; pub mod capture; pub mod clock; pub mod control; +pub mod diagnostics; pub mod discovery; pub mod encoder; pub mod gui; @@ -26,3 +27,5 @@ pub mod gpu; #[cfg(not(windows))] #[path = "gpu_other.rs"] pub mod gpu; + +pub mod telemetry; diff --git a/host/src/logging.rs b/host/src/logging.rs index a294eace..fdadecc0 100644 --- a/host/src/logging.rs +++ b/host/src/logging.rs @@ -2,7 +2,7 @@ use std::collections::{HashSet, VecDeque}; use std::fmt; use std::fs::{self, File, OpenOptions}; use std::io; -use std::io::Write; +use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex as StdMutex}; @@ -14,14 +14,21 @@ use tracing_appender::non_blocking::{NonBlocking, NonBlockingBuilder, WorkerGuar use tracing_subscriber::layer::{Context, Filter}; use tracing_subscriber::registry::LookupSpan; -const LOG_BUFFER_LIMIT: usize = 200; +const LOG_BUFFER_LIMIT: usize = 500; +const LOG_FILE_LIMIT: u64 = 2 * 1024 * 1024; +const LOG_LINE_LIMIT: usize = 16 * 1024; const OUTPUT_BUFFER_LIMIT: usize = 256; const SESSION_LOG_FILE_NAME: &str = "eternal-host-session.log"; static LOG_BUFFER: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(VecDeque::with_capacity(LOG_BUFFER_LIMIT)))); +static STARTUP_LOG: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); +static OUTPUT_ERRORS: Lazy>> = + Lazy::new(|| Mutex::new(None)); +static STORAGE_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +static LAST_STORAGE_ERROR: Lazy>> = Lazy::new(|| Mutex::new(None)); static SESSION_LOG_PATH: Lazy = Lazy::new(resolve_session_log_path); -fn open_session_log() -> Option { +fn open_session_log() -> Option { let path = SESSION_LOG_PATH.clone(); if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); @@ -31,16 +38,98 @@ fn open_session_log() -> Option { Ok(()) => true, Err(error) => { eprintln!("Could not rotate session logs: {error}"); + note_storage_error(&error); false } }; - OpenOptions::new() + let file = OpenOptions::new() .create(true) .write(true) .truncate(rotated) .append(!rotated) - .open(&path) - .ok() + .open(&path); + if let Err(error) = &file { + note_storage_error(error); + } + file.ok().map(|file| RollingLogFile { + bytes: file.metadata().map_or(0, |m| m.len()), + file: Some(file), + path, + limit: LOG_FILE_LIMIT, + }) +} + +/// Rotation runs on the output worker, never on capture/transport/GUI threads. +struct RollingLogFile { + file: Option, + path: PathBuf, + bytes: u64, + limit: u64, +} + +impl RollingLogFile { + fn write_inner(&mut self, bytes: &[u8]) -> io::Result { + if self.file.is_none() { + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + self.bytes = file.metadata()?.len(); + self.file = Some(file); + } + if self.bytes > 0 && self.bytes + bytes.len() as u64 > self.limit { + self.file.take(); // Windows cannot rename an open file. + let rotated = rotate_session_logs(&self.path).is_ok(); + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(rotated) + .append(!rotated) + .open(&self.path)?; + self.bytes = file.metadata()?.len(); + self.file = Some(file); + if !rotated { + return Err(io::Error::other("Support log rotation failed")); + } + } + let file = self + .file + .as_mut() + .ok_or_else(|| io::Error::other("Support log unavailable"))?; + let written = file.write(bytes)?; + self.bytes += written as u64; + Ok(written) + } +} + +impl Write for RollingLogFile { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let result = self.write_inner(bytes); + if let Err(error) = &result { + note_storage_error(error); + } + result + } + fn flush(&mut self) -> io::Result<()> { + if let Some(file) = self.file.as_mut() { + file.flush()?; + } + Ok(()) + } +} + +fn note_storage_error(error: &io::Error) { + STORAGE_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + *LAST_STORAGE_ERROR.lock() = Some(error.to_string()); +} + +pub fn redact_support_text(text: &str) -> String { + static SECRETS: Lazy = Lazy::new(|| { + regex::Regex::new( + r#"(?i)\b(pairing_code|pairingCode|auth_token(?:_hex)?|authToken|ingest_key|write_key|token)("?\s*[=:]\s*)("[^"]*"|\[[^\]]*\]|[^\s&,;]+)"# + ).expect("support redaction pattern") + }); + SECRETS.replace_all(text, "$1$2[REDACTED]").into_owned() } /// A stalled console or disk must not hold a capture, transport or GUI thread. @@ -84,6 +173,7 @@ impl MemoryLogWriter { let (session_file, guard) = match open_session_log() { Some(file) => { let (writer, guard) = non_blocking_output(file); + *OUTPUT_ERRORS.lock() = Some(writer.error_counter()); (Some(writer), Some(guard)) } None => (None, None), @@ -107,11 +197,19 @@ impl MemoryLogWriter { .trim_end_matches(['\r', '\n']) .to_string(); self.current_line.clear(); + let line = redact_support_text(&line); + crate::telemetry::record(&line); if line.is_empty() { return; } + if Arc::ptr_eq(&self.shared, &LOG_BUFFER) { + let mut startup = STARTUP_LOG.lock(); + if startup.len() < 200 { + startup.push(line.clone()); + } + } { let mut shared = self.shared.lock(); if shared.len() >= LOG_BUFFER_LIMIT { @@ -131,9 +229,10 @@ impl MemoryLogWriter { impl io::Write for MemoryLogWriter { fn write(&mut self, buf: &[u8]) -> io::Result { for &byte in buf { - self.current_line.push(byte); if byte == b'\n' { self.push_line(); + } else if self.current_line.len() < LOG_LINE_LIMIT { + self.current_line.push(byte); } } Ok(buf.len()) @@ -163,11 +262,54 @@ pub fn recent_log_text(limit: usize) -> Option { ) } +fn read_log_tail(path: &Path) -> io::Result { + let mut file = File::open(path)?; + let offset = file.metadata()?.len().saturating_sub(LOG_FILE_LIMIT); + file.seek(SeekFrom::Start(offset))?; + let mut bytes = Vec::new(); + file.take(LOG_FILE_LIMIT).read_to_end(&mut bytes)?; + // A legacy log may predate size limits. Skip its partial first line, including + // any secret whose field name was outside the retained tail. + let start = if offset > 0 { + bytes + .iter() + .position(|b| *b == b'\n') + .map_or(bytes.len(), |i| i + 1) + } else { + 0 + }; + Ok(String::from_utf8_lossy(&bytes[start..]).into_owned()) +} + pub fn session_log_text() -> Option { - match std::fs::read_to_string(session_log_path()) { - Ok(text) if !text.trim().is_empty() => Some(text), - _ => recent_log_text(LOG_BUFFER_LIMIT), + let dropped = OUTPUT_ERRORS + .lock() + .as_ref() + .map_or(0, |counter| counter.dropped_lines()); + let storage_failures = STORAGE_FAILURES.load(std::sync::atomic::Ordering::Relaxed); + let storage_error = LAST_STORAGE_ERROR + .lock() + .clone() + .unwrap_or_else(|| "none".to_string()); + let mut report = format!("EternalMonitor support report, schema 1\n{}\nOutput queue dropped lines: {dropped}\nStorage failures: {storage_failures}, last error: {storage_error}\nLogs may contain PC/device names and local network addresses.\n\n--- Startup evidence ---\n{}\n", + crate::diagnostics::system_report(), STARTUP_LOG.lock().join("\n")); + let path = session_log_path(); + for suffix in [".2", ".1", ""] { + let mut name = path.as_os_str().to_owned(); + name.push(suffix); + if let Ok(text) = read_log_tail(&PathBuf::from(name)) { + report.push_str(&format!("\n--- Log{suffix} ---\n{text}")); + } + } + // Include queued lines that may not have reached disk yet, especially the latest error. + if let Some(recent) = recent_log_text(LOG_BUFFER_LIMIT) { + report.push_str(&format!("\n--- Recent memory log ---\n{recent}\n")); } + report.push_str(&format!( + "\nAutomatic upload: {}\n", + crate::telemetry::status() + )); + Some(redact_support_text(&report)) } pub fn session_log_path() -> PathBuf { @@ -363,6 +505,157 @@ mod tests { } } + #[test] + fn support_redacts_credentials_but_keeps_error_codes_and_session_ids() { + let text = r#"pairing_code=123456 auth_token_hex="00112233445566778899aabbccddeeff" token=[1, 2, 3] pairingCode:654321 status=-12909 session_id=123456 "auth_token_hex": "json-secret""#; + let redacted = redact_support_text(text); + for secret in [ + "001122", + "654321", + "[1, 2, 3]", + "pairing_code=123456", + "json-secret", + ] { + assert!(!redacted.contains(secret), "leaked {secret}: {redacted}"); + } + assert!(redacted.contains("status=-12909 session_id=123456")); + } + + #[test] + fn oversized_line_is_bounded_and_does_not_leak_credential_tail() { + let shared = Arc::new(Mutex::new(VecDeque::new())); + let mut writer = MemoryLogWriter { + shared: shared.clone(), + session_file: None, + current_line: Vec::new(), + }; + writer + .write_all( + format!( + "auth_token={}\nnext event\n", + "secret".repeat(LOG_LINE_LIMIT) + ) + .as_bytes(), + ) + .unwrap(); + let lines = shared.lock(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0], "auth_token=[REDACTED]"); + assert_eq!(lines[1], "next event"); + } + + #[test] + fn legacy_oversized_log_export_is_bounded_and_skips_partial_first_line() { + let path = std::env::temp_dir().join(format!( + "eternal-tail-{}-{}.log", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let mut text = "auth_token=".to_string(); + text.push_str(&"s".repeat(LOG_FILE_LIMIT as usize)); + text.push_str("\nlatest error status=-123\n"); + fs::write(&path, text).unwrap(); + assert_eq!(read_log_tail(&path).unwrap(), "latest error status=-123\n"); + fs::remove_file(path).unwrap(); + } + + #[test] + fn storage_failure_is_visible_and_writer_can_recover() { + let directory = std::env::temp_dir().join(format!( + "eternal-storage-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let path = directory.join("blocked"); + fs::create_dir_all(&path).unwrap(); + let mut output = RollingLogFile { + file: None, + path: path.clone(), + bytes: 0, + limit: 64, + }; + assert!(output.write_all(b"failed\n").is_err()); + assert!(LAST_STORAGE_ERROR.lock().is_some()); + fs::remove_dir(&path).unwrap(); + output.write_all(b"recovered\n").unwrap(); + drop(output); + assert_eq!(fs::read_to_string(&path).unwrap(), "recovered\n"); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn running_log_rotates_by_size_and_keeps_latest_events() { + let dir = std::env::temp_dir().join(format!( + "eternal-size-log-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join(SESSION_LOG_FILE_NAME); + let mut output = RollingLogFile { + file: Some(File::create(&path).unwrap()), + path: path.clone(), + bytes: 0, + limit: 64, + }; + for n in 0..100 { + writeln!(output, "event-{n:03}").unwrap(); + } + output.flush().unwrap(); + drop(output); + assert!(fs::read_to_string(&path).unwrap().contains("event-099")); + let files: Vec<_> = fs::read_dir(&dir).unwrap().map(Result::unwrap).collect(); + assert_eq!(files.len(), 3); + for file in files { + assert!(file.metadata().unwrap().len() <= 64); + } + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn native_ffmpeg_errors_include_formatted_driver_details_in_support_logs() { + use tracing_subscriber::prelude::*; + let shared = Arc::new(Mutex::new(VecDeque::new())); + let writer = MemoryLogWriter { + shared: shared.clone(), + session_file: None, + current_line: Vec::new(), + }; + let subscriber = tracing_subscriber::registry().with( + tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(move || writer.clone()), + ); + tracing::subscriber::with_default(subscriber, || { + crate::diagnostics::install_ffmpeg_logging(); + let format = + std::ffi::CString::new("support driver probe status=%d device=%s\n").unwrap(); + let device = std::ffi::CString::new("test-gpu").unwrap(); + unsafe { + ffmpeg_next::ffi::av_log( + std::ptr::null_mut(), + ffmpeg_next::ffi::AV_LOG_ERROR, + format.as_ptr(), + -123i32, + device.as_ptr(), + ); + } + }); + assert!(shared + .lock() + .iter() + .any(|line| line.contains("support driver probe status=-123 device=test-gpu"))); + } + struct BlockedOutput { entered: Option>, release: mpsc::Receiver<()>, diff --git a/host/src/main.rs b/host/src/main.rs index f515b312..c33b743f 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -32,6 +32,8 @@ fn main() -> Result<(), Box> { .with(memory_layer) .init(); + let _uploads = eternal_host::telemetry::UploadGuard::start(); + info!( path = %logging::session_log_path().display(), "Session log file initialized" @@ -49,6 +51,7 @@ fn main() -> Result<(), Box> { { let default_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { + error!(panic = %info, backtrace = %std::backtrace::Backtrace::force_capture(), "Host panic"); vdd::disable(); default_hook(info); })); @@ -64,6 +67,8 @@ fn main() -> Result<(), Box> { error!(error = %e, "FFmpeg init failed"); return Err(e.into()); } + eternal_host::diagnostics::install_ffmpeg_logging(); + eternal_host::diagnostics::log_system_report(); let gpu_info = gpu::GpuInfo::detect(); @@ -182,6 +187,7 @@ fn main() -> Result<(), Box> { } let (supervisor_tx, supervisor_rx) = mpsc::channel(); + let _diagnostics = eternal_host::diagnostics::Monitor::start(shared.clone()); let gui_control = GuiControl { shared: shared.clone(), supervisor_tx: supervisor_tx.clone(), diff --git a/host/src/stats.rs b/host/src/stats.rs index e846f465..2d086cf8 100644 --- a/host/src/stats.rs +++ b/host/src/stats.rs @@ -11,6 +11,7 @@ pub static PIPELINE_STATS: Lazy> = const FPS_WINDOW_SECS: f64 = 1.0; const HISTORY_LEN: usize = 300; +#[derive(Clone)] pub struct PipelineStats { pub listen_addr: String, pub audio: crate::audio::AudioStats, diff --git a/host/src/telemetry/mod.rs b/host/src/telemetry/mod.rs new file mode 100644 index 00000000..73fc2568 --- /dev/null +++ b/host/src/telemetry/mod.rs @@ -0,0 +1,554 @@ +//! Automatic support uploads. A bounded channel protects media threads; a disk outbox +//! retains unacknowledged events across launches. No dashboard credential belongs here. +use serde::{Deserialize, Serialize}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc, Mutex, OnceLock, + }, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +const MAX_FILES: usize = 512; +const BATCH_SIZE: usize = 4; +static SENDER: OnceLock> = OnceLock::new(); +static ENABLED: AtomicBool = AtomicBool::new(true); +static DROPPED: AtomicU64 = AtomicU64::new(0); +static STATUS: Mutex = Mutex::new(String::new()); +static IDENTITY: OnceLock<(String, String)> = OnceLock::new(); + +#[derive(Clone, Deserialize)] +pub struct Config { + pub endpoint: String, + pub ingest_key: String, + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default)] + pub allow_http: bool, +} +fn default_enabled() -> bool { + true +} +impl Config { + fn load() -> Option { + let path = crate::settings::app_data_dir()?.join("diagnostics-upload.json"); + let mut config: Self = + fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .or_else(|| { + Some(Self { + endpoint: std::env::var("ETERNAL_DIAGNOSTICS_ENDPOINT").ok().or_else( + || option_env!("ETERNAL_DIAGNOSTICS_ENDPOINT").map(str::to_owned), + )?, + ingest_key: std::env::var("ETERNAL_DIAGNOSTICS_INGEST_KEY") + .ok() + .or_else(|| { + option_env!("ETERNAL_DIAGNOSTICS_INGEST_KEY").map(str::to_owned) + })?, + enabled: true, + allow_http: false, + }) + })?; + if let Ok(value) = std::env::var("ETERNAL_DIAGNOSTICS_ENDPOINT") { + config.endpoint = value; + } + if let Ok(value) = std::env::var("ETERNAL_DIAGNOSTICS_INGEST_KEY") { + config.ingest_key = value; + } + config.allow_http = std::env::var("ETERNAL_DIAGNOSTICS_ALLOW_HTTP").is_ok_and(|x| x == "1"); + config.valid().then_some(config) + } + fn valid(&self) -> bool { + let uri: Result = self.endpoint.parse(); + uri.is_ok_and(|uri| { + let scheme = uri.scheme_str().unwrap_or_default(); + let authority = uri.authority().map_or("", |x| x.as_str()); + (scheme == "https" || (scheme == "http" && self.allow_http)) + && !authority.is_empty() + && !authority.contains('@') + && uri.query().is_none() + }) && !self.ingest_key.is_empty() + && !self.ingest_key.contains(['\r', '\n']) + } +} + +#[derive(Clone, Serialize, Deserialize)] +struct Event { + sequence: u64, + at_ms: u64, + level: String, + category: String, + message: String, +} +#[derive(Serialize, Deserialize)] +struct Batch { + schema: u32, + batch_id: String, + installation_id: String, + launch_id: String, + platform: String, + app_version: String, + build: String, + events: Vec, +} + +fn id() -> std::io::Result { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).map_err(std::io::Error::other)?; + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} +fn set_status(text: impl Into) { + if let Ok(mut value) = STATUS.lock() { + *value = text.into(); + } +} +pub fn status() -> String { + format!( + "{}{}; queue_dropped={}", + STATUS.lock().map(|x| x.clone()).unwrap_or_default(), + IDENTITY + .get() + .map_or_else(String::new, |(installation, launch)| { + format!("; installation_id={installation}; launch_id={launch}") + }), + DROPPED.load(Ordering::Relaxed) + ) +} + +pub fn enabled() -> bool { + ENABLED.load(Ordering::Relaxed) +} +pub fn set_enabled(value: bool) { + let path = crate::settings::app_data_dir().map(|p| p.join("diagnostics-upload-disabled")); + if let Some(path) = path { + let result = if value { + if path.exists() { + fs::remove_file(path) + } else { + Ok(()) + } + } else { + fs::write(path, b"disabled") + }; + if result.is_err() { + set_status("Could not save automatic upload preference"); + return; + } + } + ENABLED.store(value, Ordering::Relaxed); + set_status(if value { + "Automatic upload enabled" + } else { + "Automatic upload disabled" + }); +} + +pub struct UploadGuard( + Option>, + Option>, +); +impl UploadGuard { + pub fn start() -> Self { + ENABLED.store( + !crate::settings::app_data_dir() + .is_some_and(|p| p.join("diagnostics-upload-disabled").exists()), + Ordering::Relaxed, + ); + let Some(config) = Config::load() else { + set_status("Automatic upload is not configured"); + return Self(None, None); + }; + if !config.enabled { + set_status("Automatic upload is disabled"); + return Self(None, None); + } + let directory = crate::settings::app_data_dir() + .unwrap() + .join("diagnostics-outbox"); + let (sender, receiver) = mpsc::sync_channel(256); + if SENDER.set(sender).is_err() { + return Self(None, None); + } + let (stop, stopped) = mpsc::channel(); + let thread = std::thread::spawn(move || { + let result = run(config, directory, receiver, stopped); + if result.is_err() { + set_status("Automatic upload stopped: local outbox unavailable"); + } + }); + Self(Some(stop), Some(thread)) + } +} +impl Drop for UploadGuard { + fn drop(&mut self) { + if let Some(stop) = self.0.take() { + let _ = stop.send(()); + } + if let Some(thread) = self.1.take() { + let _ = thread.join(); + } + } +} +pub fn record(line: &str) { + if enabled() { + if let Some(sender) = SENDER.get() { + let mut end = line.len().min(8192); + while !line.is_char_boundary(end) { + end -= 1; + } + if sender.try_send(line[..end].to_owned()).is_err() { + DROPPED.fetch_add(1, Ordering::Relaxed); + } + } + } +} +fn files(directory: &Path) -> std::io::Result> { + let mut paths: Vec<_> = fs::read_dir(directory)? + .filter_map(Result::ok) + .map(|x| x.path()) + .filter(|p| p.extension().is_some_and(|x| x == "json")) + .collect(); + paths.sort(); + Ok(paths) +} +fn persist(directory: &Path, batch: &Batch) -> std::io::Result<()> { + let bytes = serde_json::to_vec(batch)?; + let path = directory.join(format!("{}-{}.json", batch.events[0].at_ms, batch.batch_id)); + let temp = path.with_extension("tmp"); + fs::write(&temp, bytes)?; + fs::rename(temp, path)?; + let paths = files(directory)?; + let mut total: u64 = paths + .iter() + .filter_map(|p| fs::metadata(p).ok()) + .map(|m| m.len()) + .sum(); + let mut count = paths.len(); + for path in paths.iter() { + if count <= MAX_FILES && total <= 8 * 1024 * 1024 { + break; + } + total = total.saturating_sub(fs::metadata(path).map_or(0, |m| m.len())); + count -= 1; + let lost = fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .map_or(1, |batch| batch.events.len()); + fs::remove_file(path)?; + DROPPED.fetch_add(lost as u64, Ordering::Relaxed); + } + Ok(()) +} +fn upload( + agent: &ureq::Agent, + config: &Config, + path: &Path, +) -> Result<(), Box> { + let bytes = fs::read(path)?; + let batch: Batch = match serde_json::from_slice(&bytes) { + Ok(batch) => batch, + Err(_) => { + fs::remove_file(path)?; + DROPPED.fetch_add(1, Ordering::Relaxed); + set_status("A damaged upload batch was discarded"); + return Ok(()); + } + }; + let mut response = agent + .post(&config.endpoint) + .header("Authorization", format!("Bearer {}", config.ingest_key)) + .header("Content-Type", "application/json") + .send(bytes)?; + if response.status() != ureq::http::StatusCode::OK { + return Err("Upload did not return HTTP 200".into()); + } + let ack: serde_json::Value = response.body_mut().with_config().limit(4096).read_json()?; + if ack.get("accepted_batch").and_then(|x| x.as_str()) != Some(&batch.batch_id) { + return Err("Upload acknowledgement mismatch".into()); + } + fs::remove_file(path)?; + Ok(()) +} +fn run( + config: Config, + directory: PathBuf, + receiver: mpsc::Receiver, + stop: mpsc::Receiver<()>, +) -> std::io::Result<()> { + fs::create_dir_all(&directory)?; + let identity_path = directory.join("installation-id"); + let installation_id = match fs::read_to_string(&identity_path) { + Ok(id) if id.len() == 32 && id.chars().all(|c| c.is_ascii_hexdigit()) => id, + _ => { + let id = id()?; + fs::write(identity_path, &id)?; + id + } + }; + let launch_id = id()?; + let _ = IDENTITY.set((installation_id.clone(), launch_id.clone())); + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(5))) + .max_redirects(0) + .build() + .into(); + let mut pending = Vec::new(); + let mut sequence = 0; + let mut next = std::time::Instant::now(); + let mut backoff = 5; + let mut last_persist = std::time::Instant::now(); + set_status("Automatic upload enabled; waiting for events"); + loop { + let stopping = stop.try_recv().is_ok(); + match receiver.recv_timeout(Duration::from_millis(100)) { + Ok(line) => { + sequence += 1; + let level = if line.contains(" ERROR ") { + "error" + } else if line.contains(" WARN ") { + "warning" + } else { + "info" + }; + pending.push(Event { + sequence, + at_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + level: level.into(), + category: "host".into(), + message: crate::logging::redact_support_text(&line), + }); + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + if !enabled() { + pending.clear(); + for path in files(&directory)? { + fs::remove_file(path)?; + } + while receiver.try_recv().is_ok() {} + if stopping { + return Ok(()); + } + continue; + } + if pending.len() >= BATCH_SIZE + || ((!pending.is_empty()) + && (last_persist.elapsed() >= Duration::from_secs(1) || stopping)) + { + let batch = Batch { + schema: 1, + batch_id: id()?, + installation_id: installation_id.clone(), + launch_id: launch_id.clone(), + platform: std::env::consts::OS.into(), + app_version: env!("CARGO_PKG_VERSION").into(), + build: env!("ETERNAL_BUILD_REVISION").into(), + events: std::mem::take(&mut pending), + }; + persist(&directory, &batch)?; + last_persist = std::time::Instant::now(); + } + if !stopping && std::time::Instant::now() >= next { + let mut failed = false; + for path in files(&directory)?.into_iter().take(8) { + if upload(&agent, &config, &path).is_err() { + failed = true; + break; + } + } + if failed { + set_status(format!( + "Automatic upload waiting to retry; pending_batches={}", + files(&directory)?.len() + )); + backoff = (backoff * 2).min(300); + } else { + set_status(format!( + "Automatic upload connected; pending_batches={}", + files(&directory)?.len() + )); + backoff = 5; + } + next = std::time::Instant::now() + Duration::from_secs(backoff); + } + if stopping { + // Drain already accepted events to disk before returning. + while let Ok(line) = receiver.try_recv() { + sequence += 1; + pending.push(Event { + sequence, + at_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + level: "info".into(), + category: "host".into(), + message: crate::logging::redact_support_text(&line), + }); + if pending.len() >= BATCH_SIZE { + persist( + &directory, + &Batch { + schema: 1, + batch_id: id()?, + installation_id: installation_id.clone(), + launch_id: launch_id.clone(), + platform: std::env::consts::OS.into(), + app_version: env!("CARGO_PKG_VERSION").into(), + build: env!("ETERNAL_BUILD_REVISION").into(), + events: std::mem::take(&mut pending), + }, + )?; + } + } + if !pending.is_empty() { + persist( + &directory, + &Batch { + schema: 1, + batch_id: id()?, + installation_id, + launch_id, + platform: std::env::consts::OS.into(), + app_version: env!("CARGO_PKG_VERSION").into(), + build: env!("ETERNAL_BUILD_REVISION").into(), + events: pending, + }, + )?; + } + return Ok(()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn retains_batch_until_http_200_and_matching_acknowledgement() { + use std::io::{Read, Write}; + let directory = std::env::temp_dir().join(id().unwrap()); + fs::create_dir_all(&directory).unwrap(); + let path = directory.join("batch.json"); + let batch = Batch { + schema: 1, + batch_id: id().unwrap(), + installation_id: id().unwrap(), + launch_id: id().unwrap(), + platform: "windows".into(), + app_version: "0.3.0".into(), + build: "test".into(), + events: vec![], + }; + fs::write(&path, serde_json::to_vec(&batch).unwrap()).unwrap(); + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(3))) + .max_redirects(0) + .build() + .into(); + for (status, acknowledgement, accepted) in [ + (200, "wrong-batch", false), + (202, batch.batch_id.as_str(), false), + (200, batch.batch_id.as_str(), true), + ] { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let body = serde_json::json!({"accepted_batch": acknowledgement}).to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut bytes = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "request ended before body"); + bytes.extend_from_slice(&buffer[..count]); + if let Some(end) = bytes.windows(4).position(|x| x == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..end]); + let size: usize = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|value| value.trim().parse().ok()) + }) + .unwrap(); + if bytes.len() >= end + 4 + size { + break; + } + } + } + write!(stream, "HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + }); + let config = Config { + endpoint: format!("http://{address}/v1/events"), + ingest_key: "write-only".into(), + enabled: true, + allow_http: true, + }; + assert_eq!(upload(&agent, &config, &path).is_ok(), accepted); + server.join().unwrap(); + assert_eq!(path.exists(), !accepted); + } + fs::remove_dir_all(directory).unwrap(); + } + #[test] + fn rejects_plaintext_and_credentials_in_urls() { + let mut c = Config { + endpoint: "https://logs.example/v1/events".into(), + ingest_key: "write-only".into(), + enabled: true, + allow_http: false, + }; + assert!(c.valid()); + for url in [ + "http://logs.example/v1/events", + "https://secret@logs.example/v1/events", + "https://logs.example/v1/events?key=secret", + "relative", + ] { + c.endpoint = url.into(); + assert!(!c.valid()); + } + } + #[test] + fn outbox_survives_reopen_and_is_bounded() { + let directory = std::env::temp_dir().join(id().unwrap()); + fs::create_dir_all(&directory).unwrap(); + let mut batch = Batch { + schema: 1, + batch_id: id().unwrap(), + installation_id: id().unwrap(), + launch_id: id().unwrap(), + platform: "windows".into(), + app_version: "0.3.0".into(), + build: "test".into(), + events: vec![Event { + sequence: 1, + at_ms: 1, + level: "error".into(), + category: "decode".into(), + message: "status=-9".into(), + }], + }; + for _ in 0..MAX_FILES + 1 { + batch.batch_id = id().unwrap(); + persist(&directory, &batch).unwrap(); + } + let paths = files(&directory).unwrap(); + assert_eq!(paths.len(), MAX_FILES); + let saved: Batch = serde_json::from_slice(&fs::read(&paths[0]).unwrap()).unwrap(); + assert_eq!(saved.events[0].message, "status=-9"); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/host/src/transport/session.rs b/host/src/transport/session.rs index 038db19c..45e4b2b9 100644 --- a/host/src/transport/session.rs +++ b/host/src/transport/session.rs @@ -374,6 +374,11 @@ impl Session { session_id, device = %hello.device_name, screen = format!("{}x{}", hello.screen_px_w, hello.screen_px_h), + screen_points = format!("{}x{}", hello.screen_pt_w, hello.screen_pt_h), + refresh_hz = hello.refresh_hz, + preferred_fps = hello.preferred_fps, + decoder_caps = hello.decoder_caps, + feature_caps = hello.feature_caps, "Client session established" ); self.active = Some(ActiveSession { diff --git a/host/tests/amf_encoder.rs b/host/tests/amf_encoder.rs index ec4a5735..7f9ab532 100644 --- a/host/tests/amf_encoder.rs +++ b/host/tests/amf_encoder.rs @@ -108,6 +108,7 @@ fn amf_opens(name: &str) -> bool { struct StageRun { packets: Vec, software_fallback: bool, + codec_name: String, } /// Feeds `FRAME_COUNT` frames through a fresh encode stage at @@ -167,9 +168,11 @@ fn run_stage( } let packets = collector.join().unwrap(); assert!(!packets.is_empty(), "{encoder} produced no packets"); + let stats = PIPELINE_STATS.lock(); StageRun { packets, - software_fallback: PIPELINE_STATS.lock().using_software_fallback, + software_fallback: stats.using_software_fallback, + codec_name: stats.codec_name.clone(), } } @@ -328,10 +331,34 @@ fn software_encoder_follows_capture_mode_changes() { fn software_encoder_honours_keyframe_requests() { let _guard = STAGE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let run = run_stage("libx264", |_| LARGE, Some(KEYFRAME_REQUEST_AT)); + assert!( + !run.software_fallback, + "explicit CPU selection is not a fallback" + ); + assert_eq!(run.codec_name, "H.264 (x264)"); assert_h264_keyframe(&run.packets[0], "the first packet"); assert_keyframe_on_request(&run, Id::H264); } +#[cfg(windows)] +#[test] +#[ignore = "requires the NVIDIA/AMD reference PC without an Intel GPU"] +fn unavailable_quick_sync_uses_another_gpu_and_decodes() { + let _guard = STAGE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let run = run_stage("h264_qsv", |_| LARGE, None); + assert!(!run.software_fallback, "fell straight from QSV to CPU"); + assert_eq!(run.codec_name, "H.264 (NVENC)"); + assert_h264_keyframe(&run.packets[0], "the first packet"); + let pictures = decode(Id::H264, &run.packets.iter().collect::>()); + assert!(pictures.len() >= 30, "GPU fallback did not sustain video"); + assert!(pictures.windows(2).all(|pair| pair[0].2 < pair[1].2)); + eprintln!( + "Unavailable QSV recovered through {} and decoded {} packets", + run.codec_name, + run.packets.len() + ); +} + #[test] fn strict_decoder_rejects_broken_access_units() { let _guard = STAGE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/ios/EternalMonitor/App/ConnectView.swift b/ios/EternalMonitor/App/ConnectView.swift index 3dc9859d..102520f4 100644 --- a/ios/EternalMonitor/App/ConnectView.swift +++ b/ios/EternalMonitor/App/ConnectView.swift @@ -627,6 +627,10 @@ struct ConnectView: View { } .padding(.horizontal, 16) .padding(.bottom, 16) + NavigationLink("Export diagnostics") { DiagnosticsView() } + .font(.app(14, .medium, relativeTo: .body)) + .padding(.horizontal, 16) + .padding(.bottom, 16) } } .card(padding: 0) diff --git a/ios/EternalMonitor/App/ConnectionManager.swift b/ios/EternalMonitor/App/ConnectionManager.swift index d261f9b1..8e844ce1 100644 --- a/ios/EternalMonitor/App/ConnectionManager.swift +++ b/ios/EternalMonitor/App/ConnectionManager.swift @@ -279,7 +279,7 @@ final class ConnectionManager: ObservableObject { sessionWantsAudio = wantsAudio audioStats = AudioStats() debugState = ConnectionDebugState(host: normalizedHost, port: port) - record(.info, "connection", "Starting connection to \(normalizedHost):\(port)") + record(.info, "connection", "Starting connection attempt=\(connectionID) transport=\(transportMode) to \(normalizedHost):\(port) drawing=\(drawing.enabled) input=\(wantsInput) audio=\(wantsAudio)") let decoder = VideoDecoder() let metrics = ReceiverMetrics() @@ -443,7 +443,7 @@ final class ConnectionManager: ObservableObject { self.hostStreamConfig = info.streamConfig self.record( .info, "ctrl", - "Connected to \(info.hostName): \(info.streamConfig.width)x\(info.streamConfig.height) @ \(info.streamConfig.fps)fps" + "Connected to \(info.hostName) session_id=\(info.sessionId) host_caps=\(info.hostCaps) liveness_ms=\(info.livenessTimeoutMs) config=\(info.streamConfig)" ) } } @@ -538,6 +538,7 @@ final class ConnectionManager: ObservableObject { preferredFPS: drawing.preferredFPS(UserDefaults.standard.frameRatePreference(), maximum: screen.maximumFramesPerSecond), authToken: pairingToken ) + record(.info, "connection", "Client capabilities pixels=\(identity.screenPxW)x\(identity.screenPxH) refresh=\(identity.refreshHz) preferred_fps=\(identity.preferredFPS) decoder_caps=\(identity.decoderCaps) feature_caps=\(identity.featureCaps)") E2E.emit("E2E_HELLO w=\(identity.screenPxW) h=\(identity.screenPxH) refresh_hz=\(identity.refreshHz)") receiver.onListenerReady = { [weak self, weak channel] actualPort in channel?.startHandshake(listenPort: actualPort, identity: identity) @@ -567,8 +568,8 @@ final class ConnectionManager: ObservableObject { receiver.onError = { [weak self] message in Task { @MainActor in guard let self, self.currentAttempt.withLock({ $0 == connectionID }) else { return } + self.record(.error, isUSB ? "usb" : "udp", "state=\(self.state) \(message)") guard self.state == .connecting else { return } - self.record(.error, isUSB ? "usb" : "udp", message) self.connectionError = message if !isUSB { self.disconnect() } } @@ -606,6 +607,7 @@ final class ConnectionManager: ObservableObject { private func startStatsFlushLoop() { statsFlushTask?.cancel() statsFlushTask = Task { @MainActor [weak self] in + var lastSummary = ProcessInfo.processInfo.systemUptime while !Task.isCancelled { try? await Task.sleep(nanoseconds: 250_000_000) guard let self, !Task.isCancelled else { return } @@ -645,6 +647,10 @@ final class ConnectionManager: ObservableObject { // neither media nor heartbeats, so skipping this when nothing // arrived is exactly when the liveness watchdog is needed. self.refreshStatsAndWatchdog() + if ProcessInfo.processInfo.systemUptime - lastSummary >= 10 { + lastSummary = ProcessInfo.processInfo.systemUptime + self.recordStreamSummary(reason: "periodic") + } } } } @@ -847,6 +853,10 @@ final class ConnectionManager: ObservableObject { } func disconnect(reason: ByeReason = .userDisconnect) { + if state != .disconnected { + recordStreamSummary(reason: "disconnect=\(reason)") + record(.info, "connection", "Disconnect state=\(state) reason=\(reason)") + } awaitingPairing = false pairingPrompt = nil currentAttempt.withLock { $0 = nil } @@ -888,7 +898,14 @@ final class ConnectionManager: ObservableObject { UIApplication.shared.isIdleTimerDisabled = false } + private func recordStreamSummary(reason: String) { + let counters = frameAssembler?.counters.withLock { $0 } ?? .init() + let clock = controlChannel?.clockSnapshot.withLock { $0 } + SupportDiagnostics.shared.record("info", "stream", "reason=\(reason) state=\(state) transport=\(transportMode) session_id=\(hostInfo?.sessionId ?? 0) decoded=\(debugState.decodedFrames) assembled=\(debugState.assembledFrames) datagrams=\(debugState.datagramsReceived) bytes=\(debugState.datagramBytesReceived) fps=\(fps) size=\(videoSize) depth=\(videoDecoder?.decodeDepth ?? 0) dropped=\(counters.framesDropped) repaired=\(counters.fragsRepaired) nacks=\(counters.nacksSent) jitter_us=\(counters.jitterUs) rtt_us=\(clock?.rttUs ?? 0) e2e_ms=\(lagMs) audio=\(audioStats) thermal=\(ProcessInfo.processInfo.thermalState.rawValue) low_power=\(ProcessInfo.processInfo.isLowPowerModeEnabled)") + } + private func record(_ level: DiagnosticLevel, _ category: String, _ message: String) { + SupportDiagnostics.shared.record(String(describing: level), category, message) E2E.emit("E2E_DIAGNOSTIC category=\(category) message=\(message)") diagnostics.append(DiagnosticEntry(level: level, category: category, message: message)) if diagnostics.count > 80 { diff --git a/ios/EternalMonitor/App/DiagnosticsView.swift b/ios/EternalMonitor/App/DiagnosticsView.swift new file mode 100644 index 00000000..ec4c8027 --- /dev/null +++ b/ios/EternalMonitor/App/DiagnosticsView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +private struct DiagnosticsExport: Identifiable { + let id = UUID() + let url: URL +} + +private struct DiagnosticsShareSheet: UIViewControllerRepresentable { + let url: URL + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: [url], applicationActivities: nil) + } + func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} +} + +struct DiagnosticsView: View { + @State private var exported: DiagnosticsExport? + @State private var preparing = false + @State private var error: String? + @State private var copied = false + @AppStorage(DiagnosticsUpload.enabledKey) private var uploadEnabled = true + @State private var uploadStatus = "Checking automatic upload" + + + var body: some View { + Form { + Section { + Toggle("Automatically share diagnostics", isOn: $uploadEnabled) + .accessibilityIdentifier("diagnostics.automaticUpload") + .onChange(of: uploadEnabled) { _, enabled in DiagnosticsUpload.shared.setEnabled(enabled) } + Text(uploadStatus).font(.footnote) + } footer: { + Text("Device details, app errors and stream statistics are sent to EternalMonitor support while the app runs. Offline events retry automatically. Reports include PC names and network addresses. Screen content, audio and pairing credentials are excluded.") + } + .listRowBackground(Theme.surfaceRaised) + + Section { + Text("Device and app versions, connection history, stream statistics and errors are saved automatically. Recent logs remain available after restarting the app.") + Button { + preparing = true + error = nil + Task { + do { + let url = try await SupportDiagnostics.shared.export() + exported = DiagnosticsExport(url: url) + } catch { self.error = error.localizedDescription } + preparing = false + } + } label: { + Label(preparing ? "Preparing diagnostics" : "Export diagnostics", systemImage: "square.and.arrow.up") + } + .disabled(preparing) + .accessibilityIdentifier("diagnostics.export") + Button { + Task { + UIPasteboard.general.string = await SupportDiagnostics.shared.report() + copied = true + } + } label: { + Label(copied ? "Diagnostics copied" : "Copy diagnostics", systemImage: "doc.on.doc") + } + .accessibilityIdentifier("diagnostics.copy") + if let error { Text("Could not export diagnostics: \(error)") } + } footer: { + Text("Reports include local network addresses and PC names. Pairing codes and tokens are removed.") + } + .listRowBackground(Theme.surfaceRaised) + } + .scrollContentBackground(.hidden) + .background(Theme.canvas.ignoresSafeArea()) + .task { + while !Task.isCancelled { + uploadStatus = await DiagnosticsUpload.shared.status() + try? await Task.sleep(nanoseconds: 2_000_000_000) + } + } + .navigationTitle("Diagnostics") + .navigationBarTitleDisplayMode(.inline) + .sheet(item: $exported) { item in DiagnosticsShareSheet(url: item.url) } + } +} diff --git a/ios/EternalMonitor/App/EternalMonitorApp.swift b/ios/EternalMonitor/App/EternalMonitorApp.swift index add8ed8c..eae310f9 100644 --- a/ios/EternalMonitor/App/EternalMonitorApp.swift +++ b/ios/EternalMonitor/App/EternalMonitorApp.swift @@ -8,6 +8,7 @@ struct EternalMonitorApp: App { init() { FontRegistry.shared.registerBundledFonts() + SupportDiagnostics.shared.recordDeviceSnapshot() } var body: some Scene { @@ -33,11 +34,19 @@ struct EternalMonitorApp: App { .onChange(of: settings.playPCaudio) { _, enabled in connectionManager.refreshAudioPreference(enabled) } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)) { _ in + SupportDiagnostics.shared.record("warning", "device", "Memory warning") + } + .onReceive(NotificationCenter.default.publisher(for: ProcessInfo.thermalStateDidChangeNotification)) { _ in + SupportDiagnostics.shared.record("warning", "device", "Thermal state changed to \(ProcessInfo.processInfo.thermalState.rawValue)") + } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in + DiagnosticsUpload.shared.flushSoon() connectionManager.prepareForBackground() } } .onChange(of: scenePhase) { _, phase in + SupportDiagnostics.shared.record("info", "lifecycle", "scene=\(phase)") E2E.emit("E2E_SCENE phase=\(phase) uptime=\(ProcessInfo.processInfo.systemUptime)") // A backgrounded app cannot keep receiving UDP; tell the host // goodbye so it stops streaming (and can tear the virtual display diff --git a/ios/EternalMonitor/App/SettingsView.swift b/ios/EternalMonitor/App/SettingsView.swift index 307fc587..1f00d5a4 100644 --- a/ios/EternalMonitor/App/SettingsView.swift +++ b/ios/EternalMonitor/App/SettingsView.swift @@ -158,6 +158,18 @@ struct SettingsView: View { } .listRowBackground(Theme.surfaceRaised) + Section { + NavigationLink { + DiagnosticsView() + } label: { + rowLabel("Diagnostics", "doc.text.magnifyingglass") + } + .accessibilityIdentifier("settings.diagnostics") + } header: { + sectionHeader("Support") + } + .listRowBackground(Theme.surfaceRaised) + Section { HStack(spacing: 14) { LogoMark(size: 40) diff --git a/ios/EternalMonitor/Core/DiagnosticsUpload.swift b/ios/EternalMonitor/Core/DiagnosticsUpload.swift new file mode 100644 index 00000000..8c2f9edf --- /dev/null +++ b/ios/EternalMonitor/Core/DiagnosticsUpload.swift @@ -0,0 +1,188 @@ +import Foundation + +/// Automatic upload uses a write-only credential. Never put a dashboard/database key in an app. +final class DiagnosticsUpload: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + static let shared = DiagnosticsUpload() + struct Configuration { + let endpoint: URL + let ingestKey: String + let allowHTTP: Bool + var valid: Bool { + (endpoint.scheme == "https" || (allowHTTP && endpoint.scheme == "http")) && endpoint.host != nil && endpoint.user == nil && endpoint.password == nil && endpoint.query == nil && !ingestKey.isEmpty && !ingestKey.contains("\r") && !ingestKey.contains("\n") + } + static func load() -> Configuration? { + let env = ProcessInfo.processInfo.environment + let info = Bundle.main.infoDictionary ?? [:] + guard let address = env["ETERNAL_DIAGNOSTICS_ENDPOINT"] ?? info["DiagnosticsEndpoint"] as? String, + let endpoint = URL(string: address), + let key = env["ETERNAL_DIAGNOSTICS_INGEST_KEY"] ?? info["DiagnosticsIngestKey"] as? String else { return nil } + let configuration = Configuration(endpoint: endpoint, ingestKey: key, allowHTTP: env["ETERNAL_DIAGNOSTICS_ALLOW_HTTP"] == "1") + return configuration.valid ? configuration : nil + } + } + struct Event: Codable { + let sequence: Int + let at_ms: Int64 + let level: String + let category: String + let message: String + } + struct Batch: Codable { + var schema = 1 + let batch_id: String + let installation_id: String + let launch_id: String + let platform: String + let app_version: String + let build: String + let events: [Event] + } + private let queue = DispatchQueue(label: "com.eternal.diagnostics-upload", qos: .utility) + private let capacity = DispatchSemaphore(value: 256) + private let directory: URL + private let configuration: Configuration? + private let defaults: UserDefaults + private var session: URLSession! + private var timer: DispatchSourceTimer? + private var inFlight = false + private var sequence = 0 + private var pending: [Event] = [] + private var nextAttempt = Date.distantPast + private var backoff = 5.0 + private var statusText = "Automatic upload is not configured" + private let installationID: String + private let launchID = UUID().uuidString + private var dropped = 0 + private let lossLock = NSLock() + static let enabledKey = "automaticallyShareDiagnostics" + + init(directory: URL? = nil, configuration: Configuration? = Configuration.load(), defaults: UserDefaults = .standard, sessionConfiguration: URLSessionConfiguration? = nil) { + self.directory = directory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("DiagnosticsOutbox", isDirectory: true) + self.configuration = configuration + self.defaults = defaults + let identityKey = "diagnosticsInstallationID" + let installationID = defaults.string(forKey: identityKey) ?? UUID().uuidString + defaults.set(installationID, forKey: identityKey) + self.installationID = installationID + super.init() + let config = sessionConfiguration ?? URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 5 + config.timeoutIntervalForResource = 10 + session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + // A test instance without an endpoint can exercise disk persistence without network traffic. + if configuration != nil { statusText = "Automatic upload enabled; waiting for events" } + queue.async { + do { + try FileManager.default.createDirectory(at: self.directory, withIntermediateDirectories: true, + attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]) + var url = self.directory + var values = URLResourceValues(); values.isExcludedFromBackup = true + try? url.setResourceValues(values) + } catch { self.statusText = "Automatic upload outbox unavailable: \(error.localizedDescription)" } + let timer = DispatchSource.makeTimerSource(queue: self.queue) + timer.schedule(deadline: .now() + 1, repeating: 5) + timer.setEventHandler { [weak self] in self?.flush() } + self.timer = timer + timer.resume() + } + } + func shutdown() { queue.sync { timer?.cancel(); timer = nil; session.invalidateAndCancel() } } + var enabled: Bool { defaults.object(forKey: Self.enabledKey) as? Bool ?? true } + var configured: Bool { configuration != nil } + func setEnabled(_ enabled: Bool) { + defaults.set(enabled, forKey: Self.enabledKey) + queue.async { + if !enabled { + self.pending.removeAll() + self.session.getAllTasks { $0.forEach { $0.cancel() } } + for url in self.files() { try? FileManager.default.removeItem(at: url) } + self.statusText = "Automatic upload disabled" + } else { self.nextAttempt = .distantPast; self.flush() } + } + } + func record(level: String, category: String, message: String, timestamp: Date) { + guard configured, enabled else { return } + guard capacity.wait(timeout: .now()) == .success else { lossLock.lock(); dropped += 1; lossLock.unlock(); return } + let message = SupportDiagnostics.redact(String(decoding: message.utf8.prefix(8192), as: UTF8.self)) + queue.async { + defer { self.capacity.signal() } + guard self.enabled else { return } + self.sequence += 1 + self.pending.append(Event(sequence: self.sequence, at_ms: Int64(timestamp.timeIntervalSince1970 * 1000), level: level == "warn" ? "warning" : level, category: String(category.prefix(64)), message: message)) + // Persist promptly so a disconnect or process termination does not erase pending events. + self.persistPending() + } + } + private func files() -> [URL] { + ((try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.fileSizeKey])) ?? []) + .filter { $0.pathExtension == "json" }.sorted { $0.lastPathComponent < $1.lastPathComponent } + } + private func persistPending() { + while !pending.isEmpty { + let info = Bundle.main.infoDictionary ?? [:] + // Four maximally escaped 8 KiB messages fit within the API's 256 KiB limit. + let batch = Batch(batch_id: UUID().uuidString, installation_id: installationID, launch_id: launchID, platform: "ipad", app_version: info["CFBundleShortVersionString"] as? String ?? "unknown", build: info["CFBundleVersion"] as? String ?? "unknown", events: Array(pending.prefix(4))) + do { + let data = try JSONEncoder().encode(batch) + let url = directory.appendingPathComponent("\(batch.events[0].at_ms)-\(batch.batch_id).json") + try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + pending.removeFirst(batch.events.count) + let files = self.files() + var total = files.reduce(0) { $0 + ((try? $1.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) } + var count = files.count + for url in files where count > 512 || total > 8 * 1024 * 1024 { + if inFlight && url == self.activeFile { continue } + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + try FileManager.default.removeItem(at: url); total -= size; count -= 1 + lossLock.lock(); dropped += 1; lossLock.unlock() + } + } catch { + statusText = "Automatic upload outbox error: \(error.localizedDescription)" + if pending.count > 32 { let lost = pending.count - 32; pending.removeFirst(lost); lossLock.lock(); dropped += lost; lossLock.unlock() } + return + } + } + } + private var activeFile: URL? + func flushSoon() { queue.async { self.nextAttempt = .distantPast; self.flush() } } + private func flush() { + guard enabled, !inFlight, let configuration, Date() >= nextAttempt else { return } + persistPending() + guard let url = files().first else { statusText = "Automatic upload connected; outbox empty"; return } + guard let data = try? Data(contentsOf: url), let batch = try? JSONDecoder().decode(Batch.self, from: data) else { + // Remove a damaged entry so it cannot permanently block later diagnostics. + try? FileManager.default.removeItem(at: url) + lossLock.lock(); dropped += 1; lossLock.unlock(); return + } + var request = URLRequest(url: configuration.endpoint) + request.httpMethod = "POST" + request.setValue("Bearer \(configuration.ingestKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = data + inFlight = true; activeFile = url + session.dataTask(with: request) { [weak self] data, response, error in + guard let self else { return } + self.queue.async { + self.inFlight = false; self.activeFile = nil + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + let ack = data.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + if error == nil, status == 200, ack?["accepted_batch"] as? String == batch.batch_id { + try? FileManager.default.removeItem(at: url) + self.backoff = 5; self.nextAttempt = .distantPast + self.statusText = "Automatic upload connected; last sent \(ISO8601DateFormatter().string(from: Date()))" + self.flush() + } else { + self.statusText = "Automatic upload waiting to retry; HTTP=\(status) network=\((error as NSError?)?.code ?? 0) pending_batches=\(self.files().count)" + self.backoff = min(300, self.backoff * 2); self.nextAttempt = Date().addingTimeInterval(self.backoff) + } + } + }.resume() + } + func status() async -> String { + await withCheckedContinuation { continuation in queue.async { + self.lossLock.lock(); let dropped = self.dropped; self.lossLock.unlock() + continuation.resume(returning: "\(self.statusText); installation_id=\(self.installationID); launch_id=\(self.launchID); dropped=\(dropped); pending_batches=\(self.files().count)") + } } + } + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) } +} diff --git a/ios/EternalMonitor/Core/SupportDiagnostics.swift b/ios/EternalMonitor/Core/SupportDiagnostics.swift new file mode 100644 index 00000000..24dd7bb8 --- /dev/null +++ b/ios/EternalMonitor/Core/SupportDiagnostics.swift @@ -0,0 +1,156 @@ +import Foundation +import UIKit +import VideoToolbox +import os + +/// Bounded support logs survive disconnects and launches. Disk work stays off the media queues. +final class SupportDiagnostics: @unchecked Sendable { + static let shared = SupportDiagnostics() + static let fileLimit = 1_048_576 + private let queue = DispatchQueue(label: "com.eternal.support-log", qos: .utility) + private let pending = DispatchSemaphore(value: 512) + private let lossLock = NSLock() + private var dropped = 0 + private let directory: URL + private let limit: Int + private let launchID = UUID().uuidString + private var file: FileHandle? + private var bytes = 0 + private var storageError: String? + private let formatter = ISO8601DateFormatter() + private var startup: [String] = [] + private var recent: [String] = [] + + init(directory: URL? = nil, limit: Int = SupportDiagnostics.fileLimit) { + self.directory = directory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Diagnostics", isDirectory: true) + self.limit = limit + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + queue.sync { + do { + try FileManager.default.createDirectory(at: self.directory, withIntermediateDirectories: true) + try rotate() + try open() + } catch { storageError = "\(error)" } + } + } + + private func url(_ suffix: String = "") -> URL { + directory.appendingPathComponent("eternal-ipad.log\(suffix)") + } + + private func open() throws { + if !FileManager.default.fileExists(atPath: url().path) { + guard FileManager.default.createFile(atPath: url().path, contents: nil, + attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]) else { + throw CocoaError(.fileWriteUnknown) + } + } + file = try FileHandle(forWritingTo: url()) + bytes = Int(try file!.seekToEnd()) + var resourceURL = url() + var values = URLResourceValues() + values.isExcludedFromBackup = true + try? resourceURL.setResourceValues(values) + } + + private func rotate() throws { + try file?.close() + file = nil + let manager = FileManager.default + if manager.fileExists(atPath: url(".2").path) { try manager.removeItem(at: url(".2")) } + if manager.fileExists(atPath: url(".1").path) { try manager.moveItem(at: url(".1"), to: url(".2")) } + if manager.fileExists(atPath: url().path) { try manager.moveItem(at: url(), to: url(".1")) } + } + + func record(_ level: String = "info", _ category: String, _ message: String) { + let timestamp = Date() + DiagnosticsUpload.shared.record(level: level, category: category, message: message, timestamp: timestamp) + let uptime = ProcessInfo.processInfo.systemUptime + guard pending.wait(timeout: .now()) == .success else { + lossLock.lock(); dropped += 1; lossLock.unlock() + return + } + // Bound individual events before enqueueing as well as total queued events. + let message = Self.redact(String(message.prefix(8192))) + let category = String(category.prefix(64)) + queue.async { + defer { self.pending.signal() } + let line = "\(self.formatter.string(from: timestamp)) uptime=\(String(format: "%.3f", uptime)) launch=\(self.launchID) \(level) [\(category)] \(message)\n" + if self.startup.count < 100 { self.startup.append(line) } + self.recent.append(line) + if self.recent.count > 200 { self.recent.removeFirst(self.recent.count - 200) } + do { + let data = Data(line.utf8) + if self.bytes > 0 && self.bytes + data.count > self.limit { + try self.rotate() + try self.open() + } + guard let file = self.file else { throw CocoaError(.fileWriteUnknown) } + try file.write(contentsOf: data) + self.bytes += data.count + } catch { self.storageError = "\(error)" } + } + } + + /// File names are fixed and bounded. Export never reads settings, Keychain or media. + func report() async -> String { + let uploadStatus = await DiagnosticsUpload.shared.status() + return await withCheckedContinuation { continuation in + queue.async { + self.lossLock.lock(); let dropped = self.dropped; self.lossLock.unlock() + var report = "EternalMonitor iPad support report, schema 1\nLaunch: \(self.launchID)\nAutomatic upload: \(uploadStatus)\nOutput queue dropped events: \(dropped)\nStorage error: \(self.storageError ?? "none")\nLogs may contain PC/device names and local network addresses.\n\n--- Startup evidence ---\n\(self.startup.joined())" + for suffix in [".2", ".1", ""] { + if let text = try? String(contentsOf: self.url(suffix), encoding: .utf8) { + report += "\n--- Log\(suffix) ---\n\(text)" + } + } + report += "\n--- Recent memory log ---\n\(self.recent.joined())" + continuation.resume(returning: Self.redact(report)) + } + } + } + + func export() async throws -> URL { + let text = await report() + return try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("EternalMonitor-iPad-diagnostics.txt") + try text.write(to: url, atomically: true, encoding: .utf8) + continuation.resume(returning: url) + } catch { continuation.resume(throwing: error) } + } + } + } + + private static let secretPattern = try! NSRegularExpression( + pattern: #"\b(pairing_code|pairingCode|auth_token(?:_hex)?|authToken|ingest_key|write_key|token)("?\s*[=:]\s*)("[^"]*"|\[[^\]]*\]|[^\s&,;]+)"#, + options: .caseInsensitive + ) + + static func redact(_ text: String) -> String { + secretPattern.stringByReplacingMatches(in: text, range: NSRange(text.startIndex..., in: text), + withTemplate: "$1$2[REDACTED]") + } + + @MainActor + func recordDeviceSnapshot() { + let process = ProcessInfo.processInfo + var system = utsname() + uname(&system) + let machine = withUnsafeBytes(of: &system.machine) { bytes in + String(decoding: bytes.prefix(while: { $0 != 0 }), as: UTF8.self) + } + let info = Bundle.main.infoDictionary ?? [:] + let screen = UIScreen.main + #if targetEnvironment(simulator) + let simulator = true + #else + let simulator = false + #endif + record("info", "device", "app=\(info["CFBundleShortVersionString"] ?? "unknown") build=\(info["CFBundleVersion"] ?? "unknown") hardware=\(machine) simulator=\(simulator) model=\(UIDevice.current.model) OS=\(process.operatingSystemVersionString) CPUs=\(process.processorCount) RAM_bytes=\(process.physicalMemory)") + record("info", "display", "native_pixels=\(screen.nativeBounds) points=\(screen.bounds) scale=\(screen.scale) native_scale=\(screen.nativeScale) max_fps=\(screen.maximumFramesPerSecond)") + record("info", "device", "hardware_decode_h264=\(VTIsHardwareDecodeSupported(kCMVideoCodecType_H264)) hardware_decode_hevc=\(VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC)) thermal=\(process.thermalState.rawValue) low_power=\(process.isLowPowerModeEnabled)") + } +} diff --git a/ios/EternalMonitor/Decode/VideoDecoder.swift b/ios/EternalMonitor/Decode/VideoDecoder.swift index 481f1027..54ad7664 100644 --- a/ios/EternalMonitor/Decode/VideoDecoder.swift +++ b/ios/EternalMonitor/Decode/VideoDecoder.swift @@ -20,6 +20,11 @@ private let verboseDecodeLogging = false final class VideoDecoder { var onFrameDecoded: ((_ pixelBuffer: CVPixelBuffer, _ timestampUs: UInt64) -> Void)? var onEvent: ((String) -> Void)? + + private func reportFailure(_ message: String) { + SupportDiagnostics.shared.record("error", "decode", message) + onEvent?(message) + } /// The decoder cannot make progress until the next keyframe (session died, decode error). /// Protocol v2 turns this into a keyframe request to the host. var onNeedsKeyframe: (() -> Void)? @@ -262,7 +267,7 @@ final class VideoDecoder { ) } guard status == noErr, let newFormat else { - onEvent?("Failed to create HEVC format description status=\(status)") + reportFailure("Failed to create HEVC format description status=\(status)") return } adoptFormatDescription( @@ -376,7 +381,7 @@ final class VideoDecoder { guard status == noErr, let newFormat else { print("[VideoDecoder] Failed to create format description: \(status)") - onEvent?("Failed to create H.264 format description status=\(status)") + reportFailure("Failed to create H.264 format description status=\(status)") return } @@ -390,7 +395,8 @@ final class VideoDecoder { } formatDescription = newFormat - onEvent?("Updated format description \(detail)") + let dimensions = CMVideoFormatDescriptionGetDimensions(newFormat) + onEvent?("Updated format description codec=\(codec) size=\(dimensions.width)x\(dimensions.height) \(detail)") // Adopt a compatible format change without rebuilding the hardware decoder; // only an incompatible change (e.g. resolution switch) costs a session rebuild. @@ -434,26 +440,29 @@ final class VideoDecoder { guard status == noErr, let session else { print("[VideoDecoder] Failed to create decompression session: \(status)") - onEvent?("Failed to create VideoToolbox session status=\(status)") + reportFailure("Failed to create VideoToolbox session status=\(status)") return } - VTSessionSetProperty(session, key: kVTDecompressionPropertyKey_RealTime, value: kCFBooleanTrue!) + let realTimeStatus = VTSessionSetProperty(session, key: kVTDecompressionPropertyKey_RealTime, value: kCFBooleanTrue!) + if realTimeStatus != noErr { reportFailure("VideoToolbox real-time property status=\(realTimeStatus)") } - var usingHardware: CFTypeRef? - VTSessionCopyProperty( + var usingHardware: Unmanaged? + let hardwareStatus = VTSessionCopyProperty( session, key: kVTDecompressionPropertyKey_UsingHardwareAcceleratedVideoDecoder, allocator: kCFAllocatorDefault, valueOut: &usingHardware ) - let hardware = (usingHardware as? Bool) == true + let hardware = (usingHardware?.takeRetainedValue() as? Bool) == true + let hardwareKind = hardwareStatus == noErr ? (hardware ? "hardware" : "software") : "unknown" decompressionSession = session recoveryGeneration &+= 1 waitingForSyncSample = true - onEvent?("VideoToolbox session ready (\(hardware ? "hardware" : "software") decoder)") + let dimensions = CMVideoFormatDescriptionGetDimensions(formatDescription) + onEvent?("VideoToolbox session ready (\(hardwareKind) decoder) hardware_query_status=\(hardwareStatus) codec=\(codec) size=\(dimensions.width)x\(dimensions.height)") if E2E.enabled { - E2E.emit("E2E_DECODER kind=\(hardware ? "hw" : "sw")") + E2E.emit("E2E_DECODER kind=\(hardwareStatus == noErr ? (hardware ? "hw" : "sw") : "unknown")") } } @@ -489,10 +498,12 @@ final class VideoDecoder { // Create CMBlockBuffer var blockBuffer: CMBlockBuffer? + var blockStatus: OSStatus = noErr + var replaceStatus: OSStatus = noErr let totalLen = avccData.count avccData.withUnsafeMutableBytes { rawBuffer in guard let ptr = rawBuffer.baseAddress else { return } - CMBlockBufferCreateWithMemoryBlock( + blockStatus = CMBlockBufferCreateWithMemoryBlock( allocator: kCFAllocatorDefault, memoryBlock: nil, blockLength: totalLen, @@ -504,7 +515,7 @@ final class VideoDecoder { blockBufferOut: &blockBuffer ) if let blockBuffer { - CMBlockBufferReplaceDataBytes( + replaceStatus = CMBlockBufferReplaceDataBytes( with: ptr, blockBuffer: blockBuffer, offsetIntoDestination: 0, @@ -513,15 +524,15 @@ final class VideoDecoder { } } - guard let blockBuffer else { - onEvent?("Failed to allocate block buffer for access unit bytes=\(totalLen)") + guard blockStatus == noErr, replaceStatus == noErr, let blockBuffer else { + reportFailure("Failed to allocate block buffer for access unit bytes=\(totalLen) status=\(blockStatus) replace_status=\(replaceStatus)") return } // Create CMSampleBuffer var sampleBuffer: CMSampleBuffer? var sampleSize = totalLen - CMSampleBufferCreateReady( + let sampleStatus = CMSampleBufferCreateReady( allocator: kCFAllocatorDefault, dataBuffer: blockBuffer, formatDescription: formatDescription, @@ -533,8 +544,8 @@ final class VideoDecoder { sampleBufferOut: &sampleBuffer ) - guard let sampleBuffer else { - onEvent?("Failed to create sample buffer for access unit bytes=\(totalLen)") + guard sampleStatus == noErr, let sampleBuffer else { + reportFailure("Failed to create sample buffer for access unit bytes=\(totalLen) status=\(sampleStatus)") return } @@ -608,7 +619,7 @@ final class VideoDecoder { onSyncSample: Bool) { guard !isShutdown, generation == recoveryGeneration, !waitingForSyncSample else { return } waitingForSyncSample = true - onEvent?("\(source) status=\(status); waiting for a keyframe") + reportFailure("\(source) status=\(status) codec=\(codec) depth=\(decodeDepth) generation=\(generation) sync=\(onSyncSample); waiting for a keyframe") if status == kVTInvalidSessionErr || onSyncSample { // The session died underneath us (typical after app backgrounding), // or it rejected a keyframe: a session in that state rejected every diff --git a/ios/EternalMonitor/Info.plist b/ios/EternalMonitor/Info.plist index 07d3907c..9da01f42 100644 --- a/ios/EternalMonitor/Info.plist +++ b/ios/EternalMonitor/Info.plist @@ -55,6 +55,8 @@ _eternaldisplay._udp + DiagnosticsEndpoint$(ETERNAL_DIAGNOSTICS_ENDPOINT) + DiagnosticsIngestKey$(ETERNAL_DIAGNOSTICS_INGEST_KEY) NSCameraUsageDescription Scan the QR code shown in EternalMonitor on your PC to connect without typing its address. diff --git a/ios/EternalMonitor/Network/FramedLink.swift b/ios/EternalMonitor/Network/FramedLink.swift index 2e329ed8..2f960068 100644 --- a/ios/EternalMonitor/Network/FramedLink.swift +++ b/ios/EternalMonitor/Network/FramedLink.swift @@ -47,7 +47,9 @@ final class FramedLink: MediaLink { guard let self, let connection, self.connection === connection else { return } switch state { case .ready: self.receiveNext() - case .failed(let error): self.fail("USB connection failed: \(error.localizedDescription)") + case .failed(let error): + SupportDiagnostics.shared.record("error", "usb", "NWConnection failed: \(error)") + self.fail("USB connection failed: \(error.localizedDescription)") default: break } } @@ -93,7 +95,10 @@ final class FramedLink: MediaLink { connection.send(content: packet, completion: .contentProcessed { [weak self, weak connection] error in guard let self, let connection, self.connection === connection else { return } self.sending = false - if let error { self.fail("USB send failed: \(error.localizedDescription)") } + if let error { + SupportDiagnostics.shared.record("error", "usb", "NWConnection send: \(error)") + self.fail("USB send failed: \(error.localizedDescription)") + } else { self.sendNext() } }) } @@ -123,7 +128,10 @@ final class FramedLink: MediaLink { self.fail("USB protocol mismatch."); return } } - if let error { self.fail("USB receive failed: \(error.localizedDescription)") } + if let error { + SupportDiagnostics.shared.record("error", "usb", "NWConnection receive: \(error)") + self.fail("USB receive failed: \(error.localizedDescription)") + } else if complete { self.finish() } else { self.receiveNext() } } @@ -157,6 +165,7 @@ final class FramedLink: MediaLink { private func fail(_ message: String) { guard connection != nil else { return } + SupportDiagnostics.shared.record("error", "usb", message) onError?(message) finish() } diff --git a/ios/EternalMonitor/Network/UDPReceiver.swift b/ios/EternalMonitor/Network/UDPReceiver.swift index 5c43774b..ea33349a 100644 --- a/ios/EternalMonitor/Network/UDPReceiver.swift +++ b/ios/EternalMonitor/Network/UDPReceiver.swift @@ -99,17 +99,17 @@ final class UDPReceiver: MediaLink { switch state { case .ready: let actualPort = self.localPort() ?? 0 - print("[UDPReceiver] NW socket ready, ephemeral port \(actualPort)") + SupportDiagnostics.shared.record("info", "udp", "backend=Network.framework local_port=\(actualPort)") self.onListenerReady?(actualPort) guard self.connection === connection else { return } self.onConnectionEstablished?() self.startRepairTimer() self.receiveLoop() case .waiting(let error): - print("[UDPReceiver] Connection waiting: \(error)") + SupportDiagnostics.shared.record("warning", "udp", "Network.framework waiting: \(error)") self.onError?("UDP path not ready: \(error.localizedDescription)") case .failed(let error): - print("[UDPReceiver] Connection failed: \(error)") + SupportDiagnostics.shared.record("error", "udp", "Network.framework failed: \(error)") self.onError?("UDP connection failed: \(error.localizedDescription)") self.stopOnQueue() case .cancelled: @@ -178,7 +178,7 @@ final class UDPReceiver: MediaLink { readSource = source source.resume() let actualPort = localPort() ?? 0 - print("[UDPReceiver] BSD socket ready, ephemeral port \(actualPort), SO_RCVBUF=\(receiveBufferBytes)") + SupportDiagnostics.shared.record("info", "udp", "backend=bsd address_family=\(info.ai_family) local_port=\(actualPort) SO_RCVBUF=\(receiveBufferBytes)") onListenerReady?(actualPort) onConnectionEstablished?() startRepairTimer() @@ -293,7 +293,7 @@ final class UDPReceiver: MediaLink { guard let self, let connection, self.connection === connection else { return } if let error { - print("[UDPReceiver] Receive error: \(error)") + SupportDiagnostics.shared.record("error", "udp", "Network.framework receive: \(error)") // A transient error (ICMP port-unreachable while the host // restarts its pipeline, a path blip) must not silently kill // reception forever — re-arm after a short delay as long as the diff --git a/ios/EternalMonitor/PrivacyInfo.xcprivacy b/ios/EternalMonitor/PrivacyInfo.xcprivacy index 2668b66d..8f1c96c1 100644 --- a/ios/EternalMonitor/PrivacyInfo.xcprivacy +++ b/ios/EternalMonitor/PrivacyInfo.xcprivacy @@ -2,24 +2,66 @@ - NSPrivacyTracking - - NSPrivacyCollectedDataTypes - - NSPrivacyAccessedAPITypes - - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryUserDefaults - NSPrivacyAccessedAPITypeReasons - CA92.1 - - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategorySystemBootTime - NSPrivacyAccessedAPITypeReasons - 35F9.18FFB.1 - - + NSPrivacyTracking + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePerformanceData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherDiagnosticData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + 8FFB.1 + + + diff --git a/ios/EternalMonitorTests/DiagnosticsUploadTests.swift b/ios/EternalMonitorTests/DiagnosticsUploadTests.swift new file mode 100644 index 00000000..a2ff65e8 --- /dev/null +++ b/ios/EternalMonitorTests/DiagnosticsUploadTests.swift @@ -0,0 +1,125 @@ +import XCTest +@testable import EternalMonitor + +final class DiagnosticsUploadTests: XCTestCase { + func testRejectsUnencryptedAndCredentialBearingEndpoints() { + XCTAssertTrue(DiagnosticsUpload.Configuration(endpoint: URL(string: "https://logs.example/v1/events")!, ingestKey: "write-only", allowHTTP: false).valid) + for address in ["http://logs.example/v1/events", "https://secret@logs.example/v1/events", "https://logs.example/v1/events?key=secret"] { + XCTAssertFalse(DiagnosticsUpload.Configuration(endpoint: URL(string: address)!, ingestKey: "write-only", allowHTTP: false).valid) + } + } + func testOutboxPersistsRedactedEventsAndOptOutDeletesPendingUploads() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let suite = UUID().uuidString + let defaults = UserDefaults(suiteName: suite)! + defer { try? FileManager.default.removeItem(at: directory); defaults.removePersistentDomain(forName: suite) } + let config = DiagnosticsUpload.Configuration(endpoint: URL(string: "http://127.0.0.1:1/v1/events")!, ingestKey: "write-only", allowHTTP: true) + let upload = DiagnosticsUpload(directory: directory, configuration: config, defaults: defaults) + defer { upload.shutdown() } + upload.record(level: "error", category: "decode", message: "VTDecodeFrame status=-12909 pairing_code=123456 authToken=secret session_id=987654", timestamp: Date()) + let status = await upload.status() + XCTAssertTrue(status.contains("pending_batches=1"), status) + let file = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).first! + let data = try Data(contentsOf: file) + let batch = try JSONDecoder().decode(DiagnosticsUpload.Batch.self, from: data) + XCTAssertTrue(status.contains("installation_id=\(batch.installation_id)")) + XCTAssertTrue(status.contains("launch_id=\(batch.launch_id)")) + XCTAssertFalse(batch.events[0].message.contains("123456")) + XCTAssertFalse(batch.events[0].message.contains("secret")) + XCTAssertTrue(batch.events[0].message.contains("status=-12909")) + XCTAssertTrue(batch.events[0].message.contains("session_id=987654")) + upload.setEnabled(false) + _ = await upload.status() + XCTAssertTrue(try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).isEmpty) + XCTAssertFalse(upload.enabled) + } + + func testWrongAcknowledgementKeepsOutboxUntilMatchingRetry() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let suite = UUID().uuidString + let defaults = UserDefaults(suiteName: suite)! + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [DiagnosticsUploadProtocol.self] + let upload = DiagnosticsUpload(directory: directory, configuration: .init(endpoint: URL(string: "https://upload.invalid/v1/events")!, ingestKey: "write-only", allowHTTP: false), defaults: defaults, sessionConfiguration: configuration) + defer { upload.shutdown(); try? FileManager.default.removeItem(at: directory); defaults.removePersistentDomain(forName: suite) } + DiagnosticsUploadProtocol.match = false + upload.record(level: "error", category: "decode", message: "status=-12909", timestamp: Date()) + _ = await upload.status() + upload.flushSoon() + var rejected = false + for _ in 0..<100 { + if await upload.status().contains("waiting to retry") { rejected = true; break } + try await Task.sleep(nanoseconds: 10_000_000) + } + XCTAssertTrue(rejected) + XCTAssertEqual(try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).count, 1) + DiagnosticsUploadProtocol.match = true + upload.flushSoon() + var drained = false + for _ in 0..<100 { + if await upload.status().contains("pending_batches=0") { drained = true; break } + try await Task.sleep(nanoseconds: 10_000_000) + } + let finalStatus = await upload.status() + XCTAssertTrue(drained, finalStatus) + XCTAssertTrue(try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).isEmpty) + } + + func testStorageRecoverySplitsEscapedMessagesIntoAcceptedSizeBatches() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data().write(to: directory) // A file blocks outbox writes until storage recovers. + let suite = UUID().uuidString + let defaults = UserDefaults(suiteName: suite)! + let upload = DiagnosticsUpload(directory: directory, configuration: .init(endpoint: URL(string: "http://127.0.0.1:1/v1/events")!, ingestKey: "write-only", allowHTTP: true), defaults: defaults) + defer { upload.shutdown(); try? FileManager.default.removeItem(at: directory); defaults.removePersistentDomain(forName: suite) } + let message = String(repeating: "\u{01}", count: 8192) + for _ in 0..<40 { upload.record(level: "error", category: "storage", message: message, timestamp: Date()) } + let failedStatus = await upload.status() + XCTAssertTrue(failedStatus.contains("dropped=8"), failedStatus) + try FileManager.default.removeItem(at: directory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + upload.record(level: "info", category: "storage", message: "recovered", timestamp: Date()) + _ = await upload.status() + let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + var count = 0 + for file in files { + let data = try Data(contentsOf: file) + XCTAssertLessThan(data.count, 262144) + let batch = try JSONDecoder().decode(DiagnosticsUpload.Batch.self, from: data) + XCTAssertLessThanOrEqual(batch.events.count, 4) + count += batch.events.count + } + XCTAssertEqual(count, 33) + } +} + + +private final class DiagnosticsUploadProtocol: URLProtocol, @unchecked Sendable { + private static let lock = NSLock() + private static var matches = false + static var match: Bool { + get { lock.lock(); defer { lock.unlock() }; return matches } + set { lock.lock(); matches = newValue; lock.unlock() } + } + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + var body = request.httpBody ?? Data() + if let stream = request.httpBodyStream { + stream.open(); defer { stream.close() } + var buffer = [UInt8](repeating: 0, count: 4096) + while body.count < 262144 { + let count = stream.read(&buffer, maxLength: buffer.count) + if count <= 0 { break } + body.append(contentsOf: buffer.prefix(count)) + } + } + let batch = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] + let ack = Self.match ? batch?["batch_id"] as? String ?? "missing" : "wrong-batch" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: try! JSONSerialization.data(withJSONObject: ["accepted_batch": ack])) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} diff --git a/ios/EternalMonitorTests/SupportDiagnosticsTests.swift b/ios/EternalMonitorTests/SupportDiagnosticsTests.swift new file mode 100644 index 00000000..43068518 --- /dev/null +++ b/ios/EternalMonitorTests/SupportDiagnosticsTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import EternalMonitor + +final class SupportDiagnosticsTests: XCTestCase { + func testRedactsPairingCredentialsWithoutLosingSessionOrErrorCodes() { + let text = #"pairing_code=123456 auth_token_hex="00112233445566778899aabbccddeeff" token=[1, 2, 3] pairingCode:654321 status=-12909 session_id=123456 "auth_token_hex": "json-secret""# + let redacted = SupportDiagnostics.redact(text) + for secret in ["001122", "654321", "[1, 2, 3]", "pairing_code=123456", "json-secret"] { + XCTAssertFalse(redacted.contains(secret), redacted) + } + XCTAssertTrue(redacted.contains("status=-12909 session_id=123456")) + } + + func testReportSurvivesRelaunchIncludesLatestErrorAndExportsAFile() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let first = SupportDiagnostics(directory: directory) + first.record("error", "decode", "VTDecodeFrame status=-12909 token=secret") + let firstReport = await first.report() + XCTAssertTrue(firstReport.contains("VTDecodeFrame status=-12909")) + XCTAssertFalse(firstReport.contains("token=secret")) + let second = SupportDiagnostics(directory: directory) + second.record("info", "connection", "new launch") + let report = await second.report() + XCTAssertTrue(report.contains("VTDecodeFrame status=-12909")) + XCTAssertTrue(report.contains("new launch")) + XCTAssertTrue(report.contains("Storage error: none")) + let export = try await second.export() + XCTAssertTrue(try String(contentsOf: export).contains("VTDecodeFrame status=-12909")) + } + + func testStorageFailureKeepsLatestErrorInMemory() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data().write(to: directory) + defer { try? FileManager.default.removeItem(at: directory) } + let store = SupportDiagnostics(directory: directory) + for event in 0..<120 { store.record("info", "test", "event=\(event)") } + store.record("error", "decode", "latest-error status=-12909") + let report = await store.report() + XCTAssertFalse(report.contains("Storage error: none")) + XCTAssertTrue(report.contains("latest-error status=-12909")) + } + + func testRotationBoundsStorageAndRetainsLatestError() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = SupportDiagnostics(directory: directory, limit: 1024) + for event in 0..<100 { store.record("info", "test", "event=\(event) " + String(repeating: "x", count: 100)) } + store.record("error", "decode", "last-error status=-123") + let report = await store.report() + XCTAssertTrue(report.contains("last-error status=-123")) + let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.fileSizeKey]) + XCTAssertEqual(files.count, 3) + for file in files { XCTAssertLessThanOrEqual(try file.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0, 1024) } + } +} diff --git a/ios/EternalMonitorUITests/ConnectScreenTests.swift b/ios/EternalMonitorUITests/ConnectScreenTests.swift index e99999cb..efefe7ca 100644 --- a/ios/EternalMonitorUITests/ConnectScreenTests.swift +++ b/ios/EternalMonitorUITests/ConnectScreenTests.swift @@ -55,6 +55,29 @@ final class ConnectScreenTests: XCTestCase { waitForExpectations(timeout: 5) } + func testDiagnosticsCanBeCopiedAndSharedWithoutAConnection() { + let app = XCUIApplication() + app.launchArguments = ["-didSeeOnboarding", "YES", "-allowUSB", "NO"] + app.launch() + XCTAssertTrue(app.buttons["settings.button"].waitForExistence(timeout: 10)) + app.buttons["settings.button"].tap() + let diagnostics = app.buttons["settings.diagnostics"] + for _ in 0..<5 where !diagnostics.isHittable { app.swipeUp() } + XCTAssertTrue(diagnostics.waitForExistence(timeout: 5)) + diagnostics.tap() + let copy = app.buttons["diagnostics.copy"] + XCTAssertTrue(copy.waitForExistence(timeout: 5)) + copy.tap() + XCTAssertTrue(app.buttons["Diagnostics copied"].waitForExistence(timeout: 5)) + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = "support-diagnostics"; shot.lifetime = .keepAlways; add(shot) + app.buttons["diagnostics.export"].tap() + let save = app.cells["Save to Files"] + XCTAssertTrue(save.waitForExistence(timeout: 5), app.debugDescription) + save.tap() + XCTAssertTrue(app.buttons["Save"].waitForExistence(timeout: 5), app.debugDescription) + } + func testFrameRatePreference() { let app = XCUIApplication() app.launchArguments = ["-didSeeOnboarding", "YES", "-targetFPS", "60", "-allowUSB", "NO"] diff --git a/scripts/testflight-local.sh b/scripts/testflight-local.sh index cf6958c3..5bb1fe20 100755 --- a/scripts/testflight-local.sh +++ b/scripts/testflight-local.sh @@ -56,6 +56,7 @@ ARCHIVE_ARGS=(-project "$ROOT/ios/EternalMonitor.xcodeproj" -scheme EternalMonit if [ "$MODE" = dry ]; then ARCHIVE_ARGS+=(CODE_SIGNING_ALLOWED=NO) else + : "${ETERNAL_DIAGNOSTICS_ENDPOINT:?Set the automatic diagnostics HTTPS endpoint}" "${ETERNAL_DIAGNOSTICS_INGEST_KEY:?Set the write-only diagnostics ingest credential}" SDK=$(xcrun --sdk iphoneos --show-sdk-version) [ "${SDK%%.*}" -ge 26 ] || { echo "App Store Connect uploads require the iOS 26 SDK or later; select Xcode 26+ with DEVELOPER_DIR" >&2; exit 2; } TF_PRIVATE=$(mktemp -d "${TMPDIR:-/tmp}/eternal-testflight.XXXXXX") @@ -122,16 +123,23 @@ PYPROFILE EXPORT_OPTIONS="$TF_PRIVATE/exportOptions.plist" fi fi +if [ -n "${ETERNAL_DIAGNOSTICS_ENDPOINT:-}" ] || [ -n "${ETERNAL_DIAGNOSTICS_INGEST_KEY:-}" ]; then + : "${ETERNAL_DIAGNOSTICS_ENDPOINT:?Set both diagnostics build settings}" "${ETERNAL_DIAGNOSTICS_INGEST_KEY:?Set both diagnostics build settings}" + ARCHIVE_ARGS+=("ETERNAL_DIAGNOSTICS_ENDPOINT=$ETERNAL_DIAGNOSTICS_ENDPOINT" "ETERNAL_DIAGNOSTICS_INGEST_KEY=$ETERNAL_DIAGNOSTICS_INGEST_KEY") +fi echo "Archiving EternalMonitor $VERSION build $BUILD, mode=$MODE" xcodebuild "${ARCHIVE_ARGS[@]}" archive > "$OUT/archive.log" 2>&1 || { tail -70 "$OUT/archive.log"; exit 1; } python3 - "$OUT/EternalMonitor.xcarchive" "$VERSION" "$BUILD" <<'PY' -import pathlib,plistlib,sys +import os,pathlib,plistlib,sys app=pathlib.Path(sys.argv[1])/'Products/Applications/EternalMonitor.app' info=plistlib.loads((app/'Info.plist').read_bytes()) assert info['CFBundleShortVersionString']==sys.argv[2], info assert info['CFBundleVersion']==sys.argv[3], info assert info['UIDeviceFamily']==[2], info['UIDeviceFamily'] assert (app/'PrivacyInfo.xcprivacy').is_file(), 'Privacy manifest was not bundled' +if os.environ.get('ETERNAL_DIAGNOSTICS_ENDPOINT'): + assert info.get('DiagnosticsEndpoint') == os.environ['ETERNAL_DIAGNOSTICS_ENDPOINT'], 'Diagnostics endpoint was not bundled' + assert info.get('DiagnosticsIngestKey') == os.environ['ETERNAL_DIAGNOSTICS_INGEST_KEY'], 'Diagnostics ingest credential was not bundled' print(f'Archive verified: {sys.argv[2]} ({sys.argv[3]})') PY if [ "$MODE" = upload ]; then From 42e79b6907d669e1fa5bc2727f2ccbcfb101c298 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Sun, 18 Oct 2026 15:08:59 -0400 Subject: [PATCH 2/5] Update Settings reference for the diagnostics section --- host/tests/snapshots/settings.png | Bin 201879 -> 201870 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/host/tests/snapshots/settings.png b/host/tests/snapshots/settings.png index 3551b2c6668eb2eed8ab110fb1563c3731bcb2fa..217fcd84e3b348827b623963d43e9bd33d57f76d 100644 GIT binary patch delta 60554 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 delta 60760 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 Date: Sun, 18 Oct 2026 15:49:10 -0400 Subject: [PATCH 3/5] Wait for visible stream controls and completed video fade in UI tests --- .../PairingFlowTests.swift | 21 ++++++++++++++++--- .../StreamLifecycleTests.swift | 3 +++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/ios/EternalMonitorUITests/PairingFlowTests.swift b/ios/EternalMonitorUITests/PairingFlowTests.swift index 99d9fa0a..e1a70b15 100644 --- a/ios/EternalMonitorUITests/PairingFlowTests.swift +++ b/ios/EternalMonitorUITests/PairingFlowTests.swift @@ -29,8 +29,7 @@ final class PairingFlowTests: XCTestCase { XCTAssertTrue(app.buttons["display.disconnect"].waitForExistence(timeout: 10)) XCTAssertFalse(input.exists) capture("pairing-connected", app: app) - app.buttons["display.disconnect"].tap() - XCTAssertTrue(app.textFields["connect.host"].waitForExistence(timeout: 5)) + disconnect(app) XCTAssertTrue(app.images["recent.paired"].firstMatch.exists) app.terminate() // A new process must obtain its token from Keychain and skip the sheet. @@ -38,6 +37,8 @@ final class PairingFlowTests: XCTestCase { XCTAssertTrue(app.buttons["display.disconnect"].waitForExistence(timeout: 10)) XCTAssertFalse(input.exists) capture("pairing-token-reconnect", app: app) + if !app.buttons["display.settings"].exists { app.tap() } + XCTAssertTrue(app.buttons["display.settings"].waitForExistence(timeout: 5)) app.buttons["display.settings"].tap() XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 5)) let forget = app.buttons["settings.forgetPairings"] @@ -45,7 +46,7 @@ final class PairingFlowTests: XCTestCase { XCTAssertTrue(forget.isHittable) forget.tap() app.buttons["settings.done"].tap() - app.buttons["display.disconnect"].tap() + disconnect(app) app.terminate() app.launch() XCTAssertTrue(input.waitForExistence(timeout: 10), "Forget paired hosts must require a code again") @@ -53,6 +54,20 @@ final class PairingFlowTests: XCTestCase { app.terminate() } + private func disconnect(_ app: XCUIApplication) { + let connect = app.textFields["connect.host"] + // Capturing evidence can outlast the HUD's auto-hide timer on CI. + // A tap during dismissal reveals the HUD instead of disconnecting. + for _ in 0..<2 { + let button = app.buttons["display.disconnect"] + if !button.exists { app.tap() } + XCTAssertTrue(button.waitForExistence(timeout: 5)) + button.tap() + if connect.waitForExistence(timeout: 5) { return } + } + XCTFail("Disconnect did not return to the connection screen") + } + private func capture(_ name: String, app: XCUIApplication) { let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = name diff --git a/ios/EternalMonitorUITests/StreamLifecycleTests.swift b/ios/EternalMonitorUITests/StreamLifecycleTests.swift index 5177fd96..47113056 100644 --- a/ios/EternalMonitorUITests/StreamLifecycleTests.swift +++ b/ios/EternalMonitorUITests/StreamLifecycleTests.swift @@ -77,6 +77,9 @@ final class StreamLifecycleTests: XCTestCase { } private func capture(_ name: String, app: XCUIApplication) { + // "On air" marks the first decoded frame. Its display view can still + // be fading in, which darkens the pattern in an immediate screenshot. + Thread.sleep(forTimeInterval: 1) let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = name attachment.lifetime = .keepAlways From 8856dbb9b88ae63d70d1c7c56a27713986178900 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Sun, 18 Oct 2026 16:19:57 -0400 Subject: [PATCH 4/5] Service audio throughout video bursts and stabilize pairing controls --- host/src/audio/mod.rs | 8 ++++ host/src/audio/stage.rs | 14 ++++--- host/src/transport/audio.rs | 28 ++++++++++++-- host/src/transport/mod.rs | 4 ++ .../PairingFlowTests.swift | 37 ++++++++++++------- 5 files changed, 69 insertions(+), 22 deletions(-) diff --git a/host/src/audio/mod.rs b/host/src/audio/mod.rs index b2d09679..f17c3c02 100644 --- a/host/src/audio/mod.rs +++ b/host/src/audio/mod.rs @@ -59,6 +59,10 @@ pub struct AudioStats { pub packets: u64, pub quiet_packets: u64, pub dropped: u64, + pub capture_queue_dropped: u64, + pub stale_dropped: u64, + pub oversized_dropped: u64, + pub send_failed: u64, samples: VecDeque<(Instant, usize)>, } @@ -73,6 +77,10 @@ impl Default for AudioStats { packets: 0, quiet_packets: 0, dropped: 0, + capture_queue_dropped: 0, + stale_dropped: 0, + oversized_dropped: 0, + send_failed: 0, samples: VecDeque::with_capacity(64), } } diff --git a/host/src/audio/stage.rs b/host/src/audio/stage.rs index 6534fbb1..82352b8b 100644 --- a/host/src/audio/stage.rs +++ b/host/src/audio/stage.rs @@ -163,7 +163,9 @@ impl CaptureSession { Ok(()) => self.mark_discontinuity = false, Err(TrySendError::Full(_)) => { self.mark_discontinuity = true; - PIPELINE_STATS.lock().audio.dropped += 1; + let mut stats = PIPELINE_STATS.lock(); + stats.audio.dropped += 1; + stats.audio.capture_queue_dropped += 1; } Err(TrySendError::Closed(_)) => return Ok(false), } @@ -254,10 +256,12 @@ pub fn run_audio_stage( } if Instant::now() >= next_log { next_log = Instant::now() + Duration::from_secs(1); - let stats = PIPELINE_STATS.lock(); - tracing::info!(device = %stats.audio.device, kbps = stats.audio.kbps, - packets_per_sec = stats.audio.packets_per_sec, quiet_packets = stats.audio.quiet_packets, - dropped = stats.audio.dropped, "Audio stream stats"); + let stats = PIPELINE_STATS.lock().audio.clone(); + tracing::info!(device = %stats.device, kbps = stats.kbps, + packets_per_sec = stats.packets_per_sec, quiet_packets = stats.quiet_packets, + dropped = stats.dropped, capture_queue_dropped = stats.capture_queue_dropped, + stale_dropped = stats.stale_dropped, oversized_dropped = stats.oversized_dropped, + send_failed = stats.send_failed, "Audio stream stats"); } } drop(active); diff --git a/host/src/transport/audio.rs b/host/src/transport/audio.rs index f6c0f181..b53dce5b 100644 --- a/host/src/transport/audio.rs +++ b/host/src/transport/audio.rs @@ -36,7 +36,9 @@ impl AudioSender { }; if now_us.saturating_sub(queued.packet.capture_ts_us) > 100_000 { self.reset_pending = true; - PIPELINE_STATS.lock().audio.dropped += 1; + let mut stats = PIPELINE_STATS.lock(); + stats.audio.dropped += 1; + stats.audio.stale_dropped += 1; return None; } let stream = (queued.session_id, epoch); @@ -52,7 +54,9 @@ impl AudioSender { let datagram = header.encode(&queued.packet.opus).ok()?; if datagram.len() > shared.max_dgram.load(std::sync::atomic::Ordering::SeqCst) as usize { self.reset_pending = true; - PIPELINE_STATS.lock().audio.dropped += 1; + let mut stats = PIPELINE_STATS.lock(); + stats.audio.dropped += 1; + stats.audio.oversized_dropped += 1; return None; } Some((peer, datagram)) @@ -76,10 +80,28 @@ impl AudioSender { PIPELINE_STATS.lock().audio.record_sent(payload_size); } else { self.reset_pending = true; - PIPELINE_STATS.lock().audio.dropped += 1; + let mut stats = PIPELINE_STATS.lock(); + stats.audio.dropped += 1; + stats.audio.send_failed += 1; } } } + + pub async fn send_queued( + &mut self, + receiver: &mut tokio::sync::mpsc::Receiver, + links: &TransportLinks, + shared: &SharedControl, + epoch: u32, + ) { + // Bound work even if the capture thread refills the queue while we send. + for _ in 0..crate::audio::QUEUE_CAPACITY { + let Ok(packet) = receiver.try_recv() else { + break; + }; + self.send(packet, links, shared, epoch).await; + } + } } #[cfg(test)] diff --git a/host/src/transport/mod.rs b/host/src/transport/mod.rs index 5a254cd8..02255381 100644 --- a/host/src/transport/mod.rs +++ b/host/src/transport/mod.rs @@ -420,6 +420,10 @@ pub async fn start_sender( let mut frame_pacer = FramePacer::new(frag_count_usize, Instant::now()); for (index, datagram) in stored.iter().enumerate() { + // A busy machine can exhaust the pacing budget mid-frame. + // Keep serving audio even when there are no more pauses; + // otherwise a large IDR can fill its 60 ms capture queue. + audio_sender.send_queued(&mut audio_rx, &links, &shared, stream_epoch).await; if target_addr.link == LinkId::Udp && fault.enabled() { for due in fault.push(datagram, Instant::now()) { if let Err(e) = links.send_to(&due, target_addr).await { diff --git a/ios/EternalMonitorUITests/PairingFlowTests.swift b/ios/EternalMonitorUITests/PairingFlowTests.swift index e1a70b15..2b5ca41d 100644 --- a/ios/EternalMonitorUITests/PairingFlowTests.swift +++ b/ios/EternalMonitorUITests/PairingFlowTests.swift @@ -37,10 +37,7 @@ final class PairingFlowTests: XCTestCase { XCTAssertTrue(app.buttons["display.disconnect"].waitForExistence(timeout: 10)) XCTAssertFalse(input.exists) capture("pairing-token-reconnect", app: app) - if !app.buttons["display.settings"].exists { app.tap() } - XCTAssertTrue(app.buttons["display.settings"].waitForExistence(timeout: 5)) - app.buttons["display.settings"].tap() - XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 5)) + tapControl("display.settings", in: app, until: app.navigationBars["Settings"]) let forget = app.buttons["settings.forgetPairings"] for _ in 0..<3 where !forget.isHittable { app.swipeUp() } XCTAssertTrue(forget.isHittable) @@ -55,17 +52,29 @@ final class PairingFlowTests: XCTestCase { } private func disconnect(_ app: XCUIApplication) { - let connect = app.textFields["connect.host"] - // Capturing evidence can outlast the HUD's auto-hide timer on CI. - // A tap during dismissal reveals the HUD instead of disconnecting. - for _ in 0..<2 { - let button = app.buttons["display.disconnect"] - if !button.exists { app.tap() } - XCTAssertTrue(button.waitForExistence(timeout: 5)) - button.tap() - if connect.waitForExistence(timeout: 5) { return } + tapControl("display.disconnect", in: app, until: app.textFields["connect.host"]) + } + + private func appears(_ element: XCUIElement, within timeout: TimeInterval) -> Bool { + let predicate = NSPredicate(format: "exists == true") + return XCTWaiter().wait(for: [XCTNSPredicateExpectation(predicate: predicate, object: element)], + timeout: timeout) == .completed + } + + private func tapControl(_ identifier: String, in app: XCUIApplication, until result: XCUIElement) { + // The HUD can fade between its accessibility snapshot and the touch. + // Use its last position and verify the action, as AudioStreamTests does. + let control = app.buttons[identifier] + for _ in 0..<3 where !result.exists { + if !control.exists { + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + guard appears(control, within: 5), let frame = try? control.snapshot().frame else { continue } + app.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: frame.midX, dy: frame.midY)).tap() + if appears(result, within: 5) { return } } - XCTFail("Disconnect did not return to the connection screen") + XCTAssertTrue(result.exists, "\(identifier) did not open \(result)") } private func capture(_ name: String, app: XCUIApplication) { From 27578bdc77cca365888ab8a9ffa733de1c8c1275 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Sun, 18 Oct 2026 16:36:38 -0400 Subject: [PATCH 5/5] Test report metadata and share actions for diagnostics export --- ios/EternalMonitorUITests/ConnectScreenTests.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ios/EternalMonitorUITests/ConnectScreenTests.swift b/ios/EternalMonitorUITests/ConnectScreenTests.swift index efefe7ca..e0164d28 100644 --- a/ios/EternalMonitorUITests/ConnectScreenTests.swift +++ b/ios/EternalMonitorUITests/ConnectScreenTests.swift @@ -72,10 +72,20 @@ final class ConnectScreenTests: XCTestCase { let shot = XCTAttachment(screenshot: app.screenshot()) shot.name = "support-diagnostics"; shot.lifetime = .keepAlways; add(shot) app.buttons["diagnostics.export"].tap() + let share = app.navigationBars["UIActivityContentView"] + XCTAssertTrue(share.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertTrue(share.otherElements["EternalMonitor-iPad-diagnostics"].waitForExistence(timeout: 5)) + let documentType = share.otherElements.matching(NSPredicate(format: "label BEGINSWITH %@", "Text Document")).firstMatch + XCTAssertTrue(documentType.waitForExistence(timeout: 5)) let save = app.cells["Save to Files"] XCTAssertTrue(save.waitForExistence(timeout: 5), app.debugDescription) - save.tap() - XCTAssertTrue(app.buttons["Save"].waitForExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(save.isEnabled) + // Verify our exported attachment and share actions. The Files provider + // can remain uninitialized on a fresh simulator after its picker opens. + app.buttons["header.closeButton"].tap() + expectation(for: NSPredicate(format: "exists == false"), evaluatedWith: share) + waitForExpectations(timeout: 5) + XCTAssertTrue(app.buttons["diagnostics.export"].isEnabled) } func testFrameRatePreference() {