diff --git a/completions/_psysonic b/completions/_psysonic index e8391b37..524ff917 100644 --- a/completions/_psysonic +++ b/completions/_psysonic @@ -60,7 +60,7 @@ _psysonic_server_ids() { _psysonic_globals() { compadd -J options -X 'option' -- \ - --help --version --info --json --quiet --player completions + --help --version --info --json --quiet --logs --player completions } integer i pidx=0 @@ -68,12 +68,25 @@ for (( i = 2; i < CURRENT; i++ )); do [[ ${words[i]} == --player ]] && pidx=i done +if [[ ${words[CURRENT-1]} == --tail ]]; then + _message -e descriptions 'number of lines' + return +fi + if (( pidx == 0 )); then + local has_logs=0 + for (( i = 2; i < CURRENT; i++ )); do + [[ ${words[i]} == --logs ]] && has_logs=1 + done if (( CURRENT == 3 )) && [[ ${words[2]} == completions ]]; then compadd help bash zsh return fi - _psysonic_globals + if (( has_logs )); then + compadd -- --tail -f --follow + else + _psysonic_globals + fi return fi diff --git a/completions/psysonic.bash b/completions/psysonic.bash index 67fc0296..3332c6fe 100644 --- a/completions/psysonic.bash +++ b/completions/psysonic.bash @@ -54,6 +54,14 @@ _psysonic_snapshot_json() { _psysonic_complete() { local cur cur="${COMP_WORDS[COMP_CWORD]}" + local prev="" + (( COMP_CWORD > 0 )) && prev="${COMP_WORDS[COMP_CWORD-1]}" + + if [[ $prev == --tail ]]; then + _psysonic_compopt -o default + COMPREPLY=() + return + fi local i pidx=0 for (( i = 1; i < COMP_CWORD; i++ )); do @@ -61,11 +69,19 @@ _psysonic_complete() { done if (( pidx == 0 )); then + local has_logs=0 + for (( i = 1; i < COMP_CWORD; i++ )); do + [[ ${COMP_WORDS[i]} == --logs ]] && has_logs=1 + done if [[ ${COMP_WORDS[1]} == completions && COMP_CWORD -eq 2 ]]; then _psysonic_compreply_from_compgen 'help bash zsh' "$cur" return fi - _psysonic_compreply_from_compgen '--help --version --info --json --quiet --player completions' "$cur" + if (( has_logs )); then + _psysonic_compreply_from_compgen '--tail -f --follow' "$cur" + else + _psysonic_compreply_from_compgen '--help --version --info --json --quiet --logs --player completions' "$cur" + fi return fi diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index 97e497bd..e686a1c0 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -8,6 +8,10 @@ use std::path::PathBuf; #[cfg(target_os = "linux")] use std::sync::OnceLock; use std::time::{Duration, Instant}; +use std::{ + collections::VecDeque, + io::{BufRead, BufReader, Read, Seek, SeekFrom}, +}; use serde_json::Value; use tauri::{AppHandle, Emitter, Runtime}; @@ -88,6 +92,41 @@ pub fn wants_info_json(args: &[String]) -> bool { wants_info(args) && args.iter().skip(1).any(|a| a == "--json") } +pub fn wants_tail(args: &[String]) -> bool { + args.iter().skip(1).any(|a| a == "--tail") +} + +pub fn wants_logs(args: &[String]) -> bool { + args.iter().skip(1).any(|a| a == "--logs") +} + +pub fn wants_follow(args: &[String]) -> bool { + args.iter().skip(1).any(|a| a == "-f" || a == "--follow") +} + +pub fn logs_tail_lines(args: &[String]) -> Result, String> { + let mut i = 1usize; + while i < args.len() { + if args[i] == "--tail" { + let Some(raw) = args.get(i + 1) else { + return Err("--tail requires a numeric value".to_string()); + }; + if raw.starts_with('-') { + return Err("--tail requires a positive integer value".to_string()); + } + let n: usize = raw + .parse() + .map_err(|_| "--tail requires a positive integer value".to_string())?; + if n == 0 { + return Err("--tail must be greater than 0".to_string()); + } + return Ok(Some(n)); + } + i += 1; + } + Ok(None) +} + pub fn wants_quiet(args: &[String]) -> bool { args.iter() .skip(1) @@ -169,14 +208,14 @@ pub fn try_completions_dispatch(args: &[String]) -> Option { Some(0) } Some(x) => { - crate::app_eprintln!("NOT OK: unknown completions subcommand {x:?} (expected: bash, zsh, help)"); + eprintln!("NOT OK: unknown completions subcommand {x:?} (expected: bash, zsh, help)"); Some(2) } } } fn print_completions_install_help(program: &str) { - crate::app_eprintln!( + eprintln!( "Psysonic embeds bash/zsh completion scripts in this binary.\n\ \n\ Bash — try once in this shell:\n\ @@ -213,58 +252,144 @@ pub fn write_cli_snapshot(payload: &Value) -> Result<(), String> { pub fn print_help(program: &str) { let version = env!("CARGO_PKG_VERSION"); - crate::app_eprintln!("Psysonic {version}\n"); - crate::app_eprintln!("── Start ──"); - crate::app_eprintln!(" {program}"); - crate::app_eprintln!(" {program} --version | -V Print version and exit."); - crate::app_eprintln!(" {program} --help | -h Show this help.\n"); - crate::app_eprintln!("── Shell completion (scripts are embedded in the binary) ──"); - crate::app_eprintln!(" {program} completions How to enable tab completion in bash / zsh."); - crate::app_eprintln!(" {program} completions bash Print bash completion script (stdout)."); - crate::app_eprintln!(" {program} completions zsh Print zsh _psysonic script (stdout).\n"); - crate::app_eprintln!("── Snapshot (saved play state / queue) ──"); - crate::app_eprintln!(" Reads a JSON file written by the running app. Open the main window at least once."); - crate::app_eprintln!(" {program} --info Human-readable summary."); - crate::app_eprintln!(" {program} --info --json One JSON object on stdout."); - crate::app_eprintln!(" Linux: exits with an error if the primary instance is not on the session D-Bus."); - crate::app_eprintln!(" Windows / macOS: no D-Bus check; an empty or missing file means the UI has not"); - crate::app_eprintln!(" published a snapshot yet.\n"); - crate::app_eprintln!("── Remote commands (--player …) ──"); - crate::app_eprintln!(" Require the main Psysonic process. Same flags on Linux, Windows, and macOS."); - crate::app_eprintln!(" Linux: a second CLI process can forward over D-Bus without opening another window."); - crate::app_eprintln!(" Windows / macOS: handled via single-instance (a helper process may run briefly).\n"); - crate::app_eprintln!(" Global flags (place before --player when needed):"); - crate::app_eprintln!(" --quiet | -q Suppress \"OK: …\" lines (stderr errors are always shown)."); - crate::app_eprintln!(" --json With `audio-device list`, `library list`, `server list`, or `search`: JSON on stdout."); - crate::app_eprintln!(" Use {program} -q --player seek -5 so the seek delta is not parsed as a flag.\n"); - crate::app_eprintln!(" Playback"); - crate::app_eprintln!(" {program} [--quiet|-q] --player next | prev | play | pause | stop"); - crate::app_eprintln!(" {program} [--quiet|-q] --player play Track, album, or artist id (artist → shuffled library)."); - crate::app_eprintln!(" {program} [--quiet|-q] --player seek Integer delta, e.g. 15 or -10"); - crate::app_eprintln!(" {program} [--quiet|-q] --player volume <0-100> Absolute volume percent."); - crate::app_eprintln!(" {program} [--quiet|-q] --player shuffle Shuffle the current queue."); - crate::app_eprintln!(" {program} [--quiet|-q] --player repeat off|all|one"); - crate::app_eprintln!(" {program} [--quiet|-q] --player mute | unmute"); - crate::app_eprintln!(" {program} [--quiet|-q] --player star | unstar Current track (Subsonic star)."); - crate::app_eprintln!(" {program} [--quiet|-q] --player rating <0-5> Set song rating (0 clears)."); - crate::app_eprintln!(" {program} [--quiet|-q] --player reload Restart audio for the current track or reload server queue.\n"); - crate::app_eprintln!(" Audio output"); - crate::app_eprintln!(" {program} [--json] --player audio-device list"); - crate::app_eprintln!(" {program} --player audio-device set \n"); - crate::app_eprintln!(" Music library (Subsonic music folders for the active server)"); - crate::app_eprintln!(" {program} [--json] --player library list"); - crate::app_eprintln!(" {program} --player library set all | \n"); - crate::app_eprintln!(" Servers (saved profiles — same as the in-app server switcher)"); - crate::app_eprintln!(" {program} [--json] --player server list"); - crate::app_eprintln!(" {program} --player server set \n"); - crate::app_eprintln!(" Search (active server; respects library folder filter)"); - crate::app_eprintln!(" {program} [--json] --player search track "); - crate::app_eprintln!(" {program} [--json] --player search album "); - crate::app_eprintln!(" {program} [--json] --player search artist \n"); - crate::app_eprintln!(" Instant mix (from the track that is currently loaded)"); - crate::app_eprintln!(" {program} --player mix append"); - crate::app_eprintln!(" {program} --player mix new\n"); - crate::app_eprintln!("Exit: 0 on success. Errors print \"NOT OK: …\" on stderr with a non-zero status."); + eprintln!("Psysonic {version}\n"); + eprintln!("── Start ──"); + eprintln!(" {program}"); + eprintln!(" {program} --version | -V Print version and exit."); + eprintln!(" {program} --help | -h Show this help.\n"); + eprintln!("── Shell completion (scripts are embedded in the binary) ──"); + eprintln!(" {program} completions How to enable tab completion in bash / zsh."); + eprintln!(" {program} completions bash Print bash completion script (stdout)."); + eprintln!(" {program} completions zsh Print zsh _psysonic script (stdout).\n"); + eprintln!("── Snapshot (saved play state / queue) ──"); + eprintln!(" Reads a JSON file written by the running app. Open the main window at least once."); + eprintln!(" {program} --info Human-readable summary."); + eprintln!(" {program} --info --json One JSON object on stdout."); + eprintln!(" Linux: exits with an error if the primary instance is not on the session D-Bus."); + eprintln!(" Windows / macOS: no D-Bus check; an empty or missing file means the UI has not"); + eprintln!(" published a snapshot yet.\n"); + eprintln!("── Logs channel (normal + debug) ──"); + eprintln!(" {program} --logs Print recent log lines and exit."); + eprintln!(" {program} --logs --tail Print the last entries."); + eprintln!(" {program} --logs --tail -f Keep streaming new lines.\n"); + eprintln!("── Remote commands (--player …) ──"); + eprintln!(" Require the main Psysonic process. Same flags on Linux, Windows, and macOS."); + eprintln!(" Linux: a second CLI process can forward over D-Bus without opening another window."); + eprintln!(" Windows / macOS: handled via single-instance (a helper process may run briefly).\n"); + eprintln!(" Global flags (place before --player when needed):"); + eprintln!(" --quiet | -q Suppress \"OK: …\" lines (stderr errors are always shown)."); + eprintln!(" --json With `audio-device list`, `library list`, `server list`, or `search`: JSON on stdout."); + eprintln!(" Use {program} -q --player seek -5 so the seek delta is not parsed as a flag.\n"); + eprintln!(" Playback"); + eprintln!(" {program} [--quiet|-q] --player next | prev | play | pause | stop"); + eprintln!(" {program} [--quiet|-q] --player play Track, album, or artist id (artist → shuffled library)."); + eprintln!(" {program} [--quiet|-q] --player seek Integer delta, e.g. 15 or -10"); + eprintln!(" {program} [--quiet|-q] --player volume <0-100> Absolute volume percent."); + eprintln!(" {program} [--quiet|-q] --player shuffle Shuffle the current queue."); + eprintln!(" {program} [--quiet|-q] --player repeat off|all|one"); + eprintln!(" {program} [--quiet|-q] --player mute | unmute"); + eprintln!(" {program} [--quiet|-q] --player star | unstar Current track (Subsonic star)."); + eprintln!(" {program} [--quiet|-q] --player rating <0-5> Set song rating (0 clears)."); + eprintln!(" {program} [--quiet|-q] --player reload Restart audio for the current track or reload server queue.\n"); + eprintln!(" Audio output"); + eprintln!(" {program} [--json] --player audio-device list"); + eprintln!(" {program} --player audio-device set \n"); + eprintln!(" Music library (Subsonic music folders for the active server)"); + eprintln!(" {program} [--json] --player library list"); + eprintln!(" {program} --player library set all | \n"); + eprintln!(" Servers (saved profiles — same as the in-app server switcher)"); + eprintln!(" {program} [--json] --player server list"); + eprintln!(" {program} --player server set \n"); + eprintln!(" Search (active server; respects library folder filter)"); + eprintln!(" {program} [--json] --player search track "); + eprintln!(" {program} [--json] --player search album "); + eprintln!(" {program} [--json] --player search artist \n"); + eprintln!(" Instant mix (from the track that is currently loaded)"); + eprintln!(" {program} --player mix append"); + eprintln!(" {program} --player mix new\n"); + eprintln!("Exit: 0 on success. Errors print \"NOT OK: …\" on stderr with a non-zero status."); +} + +const CLI_TAIL_DEFAULT_LINES: usize = 200; + +fn print_log_tail_once(path: &std::path::Path, lines: usize) -> Result { + let file = std::fs::OpenOptions::new() + .read(true) + .open(path) + .map_err(|e| format!("open {}: {e}", path.display()))?; + let mut ring: VecDeque = VecDeque::with_capacity(lines.max(1)); + let mut reader = BufReader::new(file); + let mut line = String::new(); + loop { + line.clear(); + let n = reader.read_line(&mut line).map_err(|e| e.to_string())?; + if n == 0 { + break; + } + if ring.len() >= lines.max(1) { + ring.pop_front(); + } + ring.push_back(line.trim_end_matches('\n').to_string()); + } + for row in ring { + println!("{row}"); + } + let len = std::fs::metadata(path).map_err(|e| e.to_string())?.len(); + Ok(len) +} + +fn follow_log_file(path: &std::path::Path, mut offset: u64) -> Result<(), String> { + loop { + let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + if len < offset { + offset = 0; + } + if len > offset { + let mut f = std::fs::OpenOptions::new() + .read(true) + .open(path) + .map_err(|e| format!("open {}: {e}", path.display()))?; + f.seek(SeekFrom::Start(offset)).map_err(|e| e.to_string())?; + let mut chunk = String::new(); + f.read_to_string(&mut chunk).map_err(|e| e.to_string())?; + if !chunk.is_empty() { + print!("{chunk}"); + } + offset = len; + } + std::thread::sleep(Duration::from_millis(250)); + } +} + +/// Print from the shared normal/debug channel and exit. +pub fn run_tail_and_exit(args: &[String]) -> ! { + let tail_lines = match logs_tail_lines(args) { + Ok(Some(n)) => n, + Ok(None) => CLI_TAIL_DEFAULT_LINES, + Err(e) => { + eprintln!("NOT OK: {e}"); + std::process::exit(2); + } + }; + let path = crate::logging::cli_log_channel_path(); + if !path.exists() { + eprintln!("NOT OK: no log channel file yet at {}", path.display()); + std::process::exit(3); + } + let offset = match print_log_tail_once(&path, tail_lines) { + Ok(o) => o, + Err(e) => { + eprintln!("NOT OK: {e}"); + std::process::exit(1); + } + }; + if wants_follow(args) { + if let Err(e) = follow_log_file(&path, offset) { + eprintln!("NOT OK: {e}"); + std::process::exit(1); + } + } + std::process::exit(0); } /// Wait for the webview to write `psysonic-cli-library.json` after `cli:library-list`. @@ -585,11 +710,11 @@ pub fn run_info_and_exit(args: &[String]) -> ! { match linux_is_primary_instance_running() { Ok(true) => {} Ok(false) => { - crate::app_eprintln!("NOT OK: Psysonic is not running"); + eprintln!("NOT OK: Psysonic is not running"); std::process::exit(2); } Err(e) => { - crate::app_eprintln!("NOT OK: {e}"); + eprintln!("NOT OK: {e}"); std::process::exit(1); } } @@ -600,7 +725,7 @@ pub fn run_info_and_exit(args: &[String]) -> ! { let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); let empty = v.is_null() || v.as_object().map(|m| m.is_empty()).unwrap_or(true); if empty { - crate::app_eprintln!("NOT OK: no CLI snapshot yet — wait until the main window has loaded."); + eprintln!("NOT OK: no CLI snapshot yet — wait until the main window has loaded."); std::process::exit(3); } @@ -608,7 +733,7 @@ pub fn run_info_and_exit(args: &[String]) -> ! { match serde_json::to_string(&v) { Ok(line) => println!("{line}"), Err(e) => { - crate::app_eprintln!("NOT OK: {e}"); + eprintln!("NOT OK: {e}"); std::process::exit(1); } } diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs index d72ac4d3..78fbb040 100644 --- a/src-tauri/src/logging.rs +++ b/src-tauri/src/logging.rs @@ -1,6 +1,7 @@ #[cfg(unix)] use libc; use std::collections::VecDeque; +use std::io::Write; use std::sync::{Mutex, OnceLock}; use std::sync::atomic::{AtomicU8, Ordering}; @@ -20,6 +21,16 @@ fn log_buffer() -> &'static Mutex> { LOG_BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(LOG_BUFFER_MAX_LINES))) } +/// Shared runtime file used by CLI `--tail` to read normal/debug log channel. +pub fn cli_log_channel_path() -> std::path::PathBuf { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + if !dir.is_empty() { + return std::path::PathBuf::from(dir).join("psysonic-cli.log"); + } + } + std::env::temp_dir().join("psysonic-cli.log") +} + fn parse_logging_mode(mode: &str) -> Option { match mode.trim().to_ascii_lowercase().as_str() { "off" => Some(LoggingMode::Off), @@ -57,7 +68,15 @@ pub fn append_log_line(line: String) { if buf.len() >= LOG_BUFFER_MAX_LINES { buf.pop_front(); } - buf.push_back(line); + buf.push_back(line.clone()); + drop(buf); + let path = cli_log_channel_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(f, "{}", line); + } } pub fn export_logs_to_file(path: &str) -> Result { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 30bec0c3..e296bd0c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -85,6 +85,13 @@ fn main() { if psysonic_lib::cli::wants_info(&args) { psysonic_lib::cli::run_info_and_exit(&args); } + if psysonic_lib::cli::wants_logs(&args) { + psysonic_lib::cli::run_tail_and_exit(&args); + } + if psysonic_lib::cli::wants_tail(&args) { + eprintln!("NOT OK: --tail is only valid with --logs"); + std::process::exit(2); + } psysonic_lib::run(); }