The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.
Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.
The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.
The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
427 lines
15 KiB
Rust
427 lines
15 KiB
Rust
//! Dev diagnostics for the debug overlay (Ctrl+Shift+D): system + process
|
||
//! CPU, memory, GPU, network rates, and the loudness-normalization state of
|
||
//! the active track. Polled by the frontend only while the overlay is
|
||
//! visible — nothing here runs otherwise.
|
||
//!
|
||
//! Process figures aggregate the WHOLE process tree: the WebView2 children
|
||
//! do the actual rendering, so app.exe alone would tell a comforting lie.
|
||
|
||
use std::collections::HashMap;
|
||
use std::sync::{Arc, Mutex};
|
||
use std::time::Instant;
|
||
|
||
use serde::Serialize;
|
||
use sysinfo::{Networks, Pid, ProcessRefreshKind, ProcessesToUpdate, System};
|
||
use tauri::{Manager, State};
|
||
|
||
use player::StreamProgress;
|
||
|
||
pub struct DiagState(Mutex<Inner>);
|
||
|
||
struct Inner {
|
||
sys: System,
|
||
networks: Networks,
|
||
last_net: Instant,
|
||
gpu: Option<GpuCounter>,
|
||
started: Instant,
|
||
/// The ACTIVE track's transfer, straight from the player's own
|
||
/// StreamProgress — the overlay must read download truth at the source,
|
||
/// not the UI store's window-activity-gated mirror.
|
||
current_stream: Option<(u64, Arc<StreamProgress>)>,
|
||
/// On-deck pre-buffer transfers by transfer id, mirrored from the
|
||
/// download event stream (those updates are otherwise swallowed — no
|
||
/// player, no Downloads view). The Instant is set when the transfer
|
||
/// reaches a terminal state, so finished rows linger long enough to be
|
||
/// seen and then fall off.
|
||
preloads: HashMap<u64, (PreloadDl, Option<Instant>)>,
|
||
}
|
||
|
||
/// One on-deck transfer as the overlay sees it.
|
||
#[derive(Serialize, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PreloadDl {
|
||
pub username: String,
|
||
/// Full remote path — the frontend matches queue rows by this.
|
||
pub path: String,
|
||
pub state: String,
|
||
/// Remote queue position, when the peer reported one.
|
||
pub place: Option<u32>,
|
||
pub received: u64,
|
||
pub total: u64,
|
||
pub speed_bps: u64,
|
||
}
|
||
|
||
/// Called from the download event stream for every preload update.
|
||
pub fn note_preload(app: &tauri::AppHandle, id: u64, dl: PreloadDl, terminal: bool) {
|
||
if let Some(state) = app.try_state::<DiagState>() {
|
||
let finished = if terminal { Some(Instant::now()) } else { None };
|
||
state
|
||
.0
|
||
.lock()
|
||
.expect("diag lock")
|
||
.preloads
|
||
.insert(id, (dl, finished));
|
||
}
|
||
}
|
||
|
||
/// Called when a play path takes the transfer over: it is the CURRENT
|
||
/// track's stream from here on (note_stream reports it), not an on-deck row.
|
||
pub fn retire_preload(app: &tauri::AppHandle, id: u64) {
|
||
if let Some(state) = app.try_state::<DiagState>() {
|
||
state.0.lock().expect("diag lock").preloads.remove(&id);
|
||
}
|
||
}
|
||
|
||
/// Called by the play paths: this track streams from a peer.
|
||
pub fn note_stream(app: &tauri::AppHandle, total: u64, progress: Arc<StreamProgress>) {
|
||
if let Some(state) = app.try_state::<DiagState>() {
|
||
state.0.lock().expect("diag lock").current_stream = Some((total, progress));
|
||
}
|
||
}
|
||
|
||
/// Called by the play paths: this track plays from a complete local file.
|
||
pub fn clear_stream(app: &tauri::AppHandle) {
|
||
if let Some(state) = app.try_state::<DiagState>() {
|
||
state.0.lock().expect("diag lock").current_stream = None;
|
||
}
|
||
}
|
||
|
||
pub fn init(app: &tauri::AppHandle) {
|
||
app.manage(DiagState(Mutex::new(Inner {
|
||
sys: System::new(),
|
||
networks: Networks::new_with_refreshed_list(),
|
||
last_net: Instant::now(),
|
||
gpu: GpuCounter::open(),
|
||
started: Instant::now(),
|
||
current_stream: None,
|
||
preloads: HashMap::new(),
|
||
})));
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct DebugStats {
|
||
/// Whole-process-tree CPU, percent of the whole machine.
|
||
pub cpu_process: f32,
|
||
pub cpu_system: f32,
|
||
/// GPU 3D-engine utilization (Task-Manager-style), if counters opened.
|
||
/// MACHINE-WIDE — includes DWM, browsers, Discord, everything.
|
||
pub gpu_percent: Option<f32>,
|
||
/// Same counters, restricted to OUR process tree — the app's true GPU
|
||
/// share, matching Task Manager's per-process rows. The machine-wide
|
||
/// number above routinely reads 2× this on a busy desktop.
|
||
pub gpu_app_percent: Option<f32>,
|
||
/// Process tree working set / private commit, bytes.
|
||
pub mem_working_set: u64,
|
||
pub mem_private: u64,
|
||
pub mem_system_used: u64,
|
||
pub mem_system_total: u64,
|
||
/// System-wide network rates since the previous poll, bytes/sec.
|
||
pub net_rx_bps: f64,
|
||
pub net_tx_bps: f64,
|
||
pub process_count: usize,
|
||
pub uptime_secs: u64,
|
||
/// Active track's transfer, from the player's own progress handle.
|
||
pub dl_received: Option<u64>,
|
||
pub dl_total: Option<u64>,
|
||
pub dl_done: Option<bool>,
|
||
pub normalization_enabled: bool,
|
||
pub target_lufs: f64,
|
||
pub loudness: Option<crate::loudness::CurrentTrack>,
|
||
/// Main player's live fade-gain (1.0 = full; <1 only during a preview
|
||
/// open/close crossfade). Proves a fade never strands the volume.
|
||
pub main_fade_gain: Option<f32>,
|
||
/// The normalization gain the sink has RIGHT NOW, and the one the audio
|
||
/// thread is slewing toward, both dB. Reported as a pair because the
|
||
/// interesting state during a mid-track correction is the gap between
|
||
/// them: equal means settled, different means a correction is in flight
|
||
/// and the difference is how far it still has to travel.
|
||
pub norm_now_db: Option<f32>,
|
||
pub norm_target_db: Option<f32>,
|
||
/// The preview voices' resting volume (mirrors the app volume; 0 = muted).
|
||
pub preview_volume: Option<f32>,
|
||
/// The active preview clip's loudness-normalization gain in dB.
|
||
pub preview_norm_db: Option<f32>,
|
||
/// On-deck pre-buffer transfers, in arrival order (finished rows linger
|
||
/// ~15s so a completion is visible before it drops off).
|
||
pub preload_dl: Vec<PreloadDl>,
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugStats {
|
||
let mut inner = state.0.lock().expect("diag lock");
|
||
let inner = &mut *inner;
|
||
|
||
inner.sys.refresh_cpu_usage();
|
||
inner.sys.refresh_processes_specifics(
|
||
ProcessesToUpdate::All,
|
||
true,
|
||
ProcessRefreshKind::nothing().with_cpu().with_memory(),
|
||
);
|
||
|
||
// Our process tree: app.exe + every descendant (the WebView2 family).
|
||
let me = Pid::from_u32(std::process::id());
|
||
let mut tree = vec![me];
|
||
let mut grew = true;
|
||
while grew {
|
||
grew = false;
|
||
for (pid, proc_) in inner.sys.processes() {
|
||
if let Some(parent) = proc_.parent() {
|
||
if tree.contains(&parent) && !tree.contains(pid) {
|
||
tree.push(*pid);
|
||
grew = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let cores = inner.sys.cpus().len().max(1) as f32;
|
||
let mut cpu_process = 0.0f32;
|
||
let mut mem_working_set = 0u64;
|
||
let mut mem_private = 0u64;
|
||
for pid in &tree {
|
||
if let Some(p) = inner.sys.process(*pid) {
|
||
cpu_process += p.cpu_usage(); // % of one core
|
||
mem_working_set += p.memory();
|
||
// sysinfo's virtual_memory() is the raw address space on Windows
|
||
// (WebView reserves terabytes of it) — useless. The meaningful
|
||
// "reserved" figure is the private commit, straight from Win32.
|
||
mem_private += private_commit(pid.as_u32());
|
||
}
|
||
}
|
||
cpu_process /= cores;
|
||
|
||
inner.sys.refresh_memory();
|
||
|
||
// Network: sysinfo reports bytes since the LAST refresh per interface.
|
||
let elapsed = inner.last_net.elapsed().as_secs_f64().max(0.05);
|
||
inner.last_net = Instant::now();
|
||
inner.networks.refresh(true);
|
||
let (mut rx, mut tx) = (0u64, 0u64);
|
||
for (_, data) in inner.networks.iter() {
|
||
rx += data.received();
|
||
tx += data.transmitted();
|
||
}
|
||
|
||
let (normalization_enabled, target_lufs, loudness) = crate::loudness::snapshot(&app);
|
||
let (dl_received, dl_total, dl_done) = match &inner.current_stream {
|
||
Some((total, progress)) => {
|
||
let (received, done) = progress.received_done();
|
||
(Some(received), Some(*total), Some(done))
|
||
}
|
||
None => (None, None, None),
|
||
};
|
||
|
||
let tree_u32: Vec<u32> = tree.iter().map(|p| p.as_u32()).collect();
|
||
let gpu_sample = inner.gpu.as_mut().and_then(|g| g.sample(&tree_u32));
|
||
|
||
// On-deck transfers: drop terminal rows after they've been visible a
|
||
// while, then report the rest in arrival (transfer-id) order.
|
||
inner
|
||
.preloads
|
||
.retain(|_, v| v.1.is_none_or(|t| t.elapsed().as_secs() < 15));
|
||
let mut preload_ids: Vec<u64> = inner.preloads.keys().copied().collect();
|
||
preload_ids.sort_unstable();
|
||
let preload_dl: Vec<PreloadDl> = preload_ids
|
||
.iter()
|
||
.filter_map(|id| inner.preloads.get(id).map(|(dl, _)| dl.clone()))
|
||
.collect();
|
||
|
||
DebugStats {
|
||
cpu_process,
|
||
cpu_system: inner.sys.global_cpu_usage(),
|
||
gpu_percent: gpu_sample.map(|(sys, _)| sys),
|
||
gpu_app_percent: gpu_sample.map(|(_, app)| app),
|
||
mem_working_set,
|
||
mem_private,
|
||
mem_system_used: inner.sys.used_memory(),
|
||
mem_system_total: inner.sys.total_memory(),
|
||
net_rx_bps: rx as f64 / elapsed,
|
||
net_tx_bps: tx as f64 / elapsed,
|
||
process_count: tree.len(),
|
||
uptime_secs: inner.started.elapsed().as_secs(),
|
||
dl_received,
|
||
dl_total,
|
||
dl_done,
|
||
normalization_enabled,
|
||
target_lufs,
|
||
loudness,
|
||
norm_now_db: app
|
||
.try_state::<crate::playback::PlayerState>()
|
||
.map(|s| s.player.norm_gain_now_target_db().0),
|
||
norm_target_db: app
|
||
.try_state::<crate::playback::PlayerState>()
|
||
.map(|s| s.player.norm_gain_now_target_db().1),
|
||
preview_volume: app
|
||
.try_state::<crate::preview::PreviewState>()
|
||
.map(|s| crate::preview::target_volume(&s)),
|
||
preview_norm_db: app
|
||
.try_state::<crate::preview::PreviewState>()
|
||
.map(|s| crate::preview::active_norm_db(&s)),
|
||
main_fade_gain: app
|
||
.try_state::<crate::playback::PlayerState>()
|
||
.map(|p| p.player.fade_gain()),
|
||
preload_dl,
|
||
}
|
||
}
|
||
|
||
/// Private commit ("reserved") of one process, bytes. 0 if unreadable.
|
||
#[cfg(windows)]
|
||
fn private_commit(pid: u32) -> u64 {
|
||
use windows::Win32::Foundation::CloseHandle;
|
||
use windows::Win32::System::ProcessStatus::{
|
||
K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX,
|
||
};
|
||
use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
|
||
unsafe {
|
||
let Ok(handle) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else {
|
||
return 0;
|
||
};
|
||
let mut counters = PROCESS_MEMORY_COUNTERS_EX::default();
|
||
let ok = K32GetProcessMemoryInfo(
|
||
handle,
|
||
(&mut counters as *mut PROCESS_MEMORY_COUNTERS_EX).cast::<PROCESS_MEMORY_COUNTERS>(),
|
||
std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
|
||
)
|
||
.as_bool();
|
||
let _ = CloseHandle(handle);
|
||
if ok {
|
||
counters.PrivateUsage as u64
|
||
} else {
|
||
0
|
||
}
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------ GPU (PDH) ---
|
||
|
||
/// Total 3D-engine utilization via the PDH "GPU Engine" counter set — the
|
||
/// number Task Manager labels "GPU". Instances are per-process-per-engine;
|
||
/// summing the 3D ones gives machine-wide utilization. First sample after
|
||
/// opening reads as None (PDH rate counters need two collections).
|
||
#[cfg(windows)]
|
||
struct GpuCounter {
|
||
query: windows::Win32::System::Performance::PDH_HQUERY,
|
||
counter: windows::Win32::System::Performance::PDH_HCOUNTER,
|
||
}
|
||
|
||
// PDH handles are plain registry-ish handles; the query is only touched
|
||
// under the DiagState mutex.
|
||
#[cfg(windows)]
|
||
unsafe impl Send for GpuCounter {}
|
||
|
||
#[cfg(windows)]
|
||
impl GpuCounter {
|
||
fn open() -> Option<GpuCounter> {
|
||
use windows::core::w;
|
||
use windows::Win32::System::Performance::{
|
||
PdhAddEnglishCounterW, PdhOpenQueryW, PDH_HCOUNTER, PDH_HQUERY,
|
||
};
|
||
unsafe {
|
||
let mut query = PDH_HQUERY::default();
|
||
if PdhOpenQueryW(None, 0, &mut query) != 0 {
|
||
return None;
|
||
}
|
||
let mut counter = PDH_HCOUNTER::default();
|
||
let path = w!("\\GPU Engine(*engtype_3D)\\Utilization Percentage");
|
||
if PdhAddEnglishCounterW(query, path, 0, &mut counter) != 0 {
|
||
return None;
|
||
}
|
||
Some(GpuCounter { query, counter })
|
||
}
|
||
}
|
||
|
||
/// Returns (machine_total, app_tree_total). Instance names carry the
|
||
/// owning pid — "pid_1234_luid_..._engtype_3D" — so the app's share is
|
||
/// the sum over instances whose pid is in our process tree.
|
||
fn sample(&mut self, app_pids: &[u32]) -> Option<(f32, f32)> {
|
||
use windows::Win32::System::Performance::{
|
||
PdhCollectQueryData, PdhGetFormattedCounterArrayW, PDH_FMT_COUNTERVALUE_ITEM_W,
|
||
PDH_FMT_DOUBLE,
|
||
};
|
||
unsafe {
|
||
if PdhCollectQueryData(self.query) != 0 {
|
||
return None;
|
||
}
|
||
let mut buf_len: u32 = 0;
|
||
let mut count: u32 = 0;
|
||
// Size probe returns PDH_MORE_DATA; then fetch.
|
||
let _ = PdhGetFormattedCounterArrayW(
|
||
self.counter,
|
||
PDH_FMT_DOUBLE,
|
||
&mut buf_len,
|
||
&mut count,
|
||
None,
|
||
);
|
||
if buf_len == 0 {
|
||
return None;
|
||
}
|
||
let mut buf = vec![0u8; buf_len as usize];
|
||
let items = buf.as_mut_ptr() as *mut PDH_FMT_COUNTERVALUE_ITEM_W;
|
||
if PdhGetFormattedCounterArrayW(
|
||
self.counter,
|
||
PDH_FMT_DOUBLE,
|
||
&mut buf_len,
|
||
&mut count,
|
||
Some(items),
|
||
) != 0
|
||
{
|
||
return None;
|
||
}
|
||
let mut total = 0.0f64;
|
||
let mut app = 0.0f64;
|
||
for i in 0..count as usize {
|
||
let item = &*items.add(i);
|
||
let v = item.FmtValue.Anonymous.doubleValue;
|
||
total += v;
|
||
if !item.szName.is_null() {
|
||
if let Some(pid) = instance_pid(item.szName) {
|
||
if app_pids.contains(&pid) {
|
||
app += v;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Some((total.min(100.0) as f32, app.min(100.0) as f32))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Parse the pid out of a "pid_1234_luid_…" counter-instance name.
|
||
#[cfg(windows)]
|
||
unsafe fn instance_pid(name: windows::core::PWSTR) -> Option<u32> {
|
||
let s = name.to_string().ok()?;
|
||
let rest = s.strip_prefix("pid_")?;
|
||
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||
digits.parse().ok()
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
impl Drop for GpuCounter {
|
||
fn drop(&mut self) {
|
||
use windows::Win32::System::Performance::PdhCloseQuery;
|
||
unsafe {
|
||
let _ = PdhCloseQuery(self.query);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Off Windows there is no PDH and no process-memory API of this shape: the
|
||
// overlay's private-commit column reads 0 and the GPU column stays None.
|
||
#[cfg(not(windows))]
|
||
fn private_commit(_pid: u32) -> u64 {
|
||
0
|
||
}
|
||
|
||
#[cfg(not(windows))]
|
||
struct GpuCounter;
|
||
|
||
#[cfg(not(windows))]
|
||
impl GpuCounter {
|
||
fn open() -> Option<GpuCounter> {
|
||
None
|
||
}
|
||
fn sample<T: ?Sized>(&mut self, _tree: &T) -> Option<(f32, f32)> {
|
||
None
|
||
}
|
||
}
|