Files
backend-local/src/main.rs

568 lines
18 KiB
Rust
Raw Normal View History

2026-09-05 01:26:17 +03:00
use axum::{
2026-09-17 03:52:43 +03:00
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State},
2026-09-05 01:26:17 +03:00
http::StatusCode,
response::IntoResponse,
2026-09-17 03:52:43 +03:00
routing::{get, post},
Json, Router,
2026-09-05 01:26:17 +03:00
};
2026-09-17 03:52:43 +03:00
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
path::{Path, PathBuf},
process::Command,
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
};
use tempfile::{NamedTempFile, TempPath};
2026-09-05 01:26:17 +03:00
use tokio::fs;
use tower_http::cors::CorsLayer;
2026-09-18 20:25:02 +03:00
use tracing::info;
async fn get_version() -> impl IntoResponse {
Json(serde_json::json!({
"version": "1.0.7",
"timestamp": chrono::Utc::now().timestamp()
}))
}
2026-09-17 03:52:43 +03:00
// ─────────────────────────────── Модели ───────────────────────────────
2026-09-18 21:30:19 +03:00
#[derive(Deserialize, Clone, Debug)]
2026-09-05 01:26:17 +03:00
struct PrintSettings {
filename: String,
pages: String,
copies: u32,
#[serde(rename = "colorMode")]
color_mode: String,
format: String,
2026-09-17 03:52:43 +03:00
#[serde(default)]
sides: Option<String>,
#[serde(default)]
dpi: Option<String>,
}
#[derive(Clone, Debug, Serialize, PartialEq)]
#[serde(rename_all = "snake_case")]
enum FileStatus {
Queued,
Printing,
2026-09-18 20:25:02 +03:00
Sent,
2026-09-18 21:30:19 +03:00
AwaitingClearOutput,
2026-09-17 18:59:00 +03:00
AwaitingFlip,
2026-09-18 21:30:19 +03:00
AwaitingPickup,
2026-09-17 03:52:43 +03:00
Done,
Error,
Cancelled,
}
#[derive(Clone, Debug, Serialize, PartialEq)]
#[serde(rename_all = "snake_case")]
enum JobPhase {
WaitingStart,
Printing,
2026-09-18 20:25:02 +03:00
AwaitingClearOutput,
2026-09-17 18:59:00 +03:00
AwaitingFlip,
2026-09-17 03:52:43 +03:00
AwaitingPickup,
Finished,
Cancelled,
}
#[derive(Clone, Debug, Serialize)]
struct JobFileView {
name: String,
status: FileStatus,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
2026-09-17 18:59:00 +03:00
#[serde(default)]
remove_top_sheet: bool,
2026-09-17 03:52:43 +03:00
}
#[derive(Clone, Debug, Serialize)]
struct JobView {
job_id: String,
phase: JobPhase,
files: Vec<JobFileView>,
}
struct JobFile {
name: String,
2026-09-18 20:25:02 +03:00
path: TempPath,
2026-09-17 03:52:43 +03:00
settings: Option<PrintSettings>,
status: FileStatus,
error: Option<String>,
}
struct Job {
id: String,
files: Vec<JobFile>,
current: Option<usize>,
phase: JobPhase,
cups_id: Option<String>,
2026-09-18 21:30:19 +03:00
/// true если только что завершился ПЕРВЫЙ проход дуплекса
duplex_first_pass_done: bool,
2026-09-05 01:26:17 +03:00
}
2026-09-17 03:52:43 +03:00
impl Job {
fn view(&self) -> JobView {
JobView {
job_id: self.id.clone(),
phase: self.phase.clone(),
2026-09-17 18:59:00 +03:00
files: self.files.iter().map(|f| {
JobFileView {
2026-09-17 03:52:43 +03:00
name: f.name.clone(),
status: f.status.clone(),
error: f.error.clone(),
2026-09-17 18:59:00 +03:00
remove_top_sheet: Self::should_remove_top_sheet(&f.settings),
}
}).collect(),
2026-09-17 03:52:43 +03:00
}
}
2026-09-17 18:59:00 +03:00
2026-09-18 21:30:19 +03:00
fn is_current_duplex(&self) -> bool {
self.current
.and_then(|i| self.files.get(i))
.and_then(|f| f.settings.as_ref())
.map(|s| s.sides.as_deref() == Some("Двусторонняя"))
.unwrap_or(false)
}
2026-09-17 18:59:00 +03:00
fn should_remove_top_sheet(settings: &Option<PrintSettings>) -> bool {
let Some(s) = settings else { return false };
if s.sides.as_deref() != Some("Двусторонняя") { return false; }
2026-09-18 20:25:02 +03:00
if s.pages == "all" { return false; }
2026-09-17 18:59:00 +03:00
let nums: Vec<u32> = s.pages.split(',')
.filter_map(|p| p.trim().parse().ok())
.collect();
let odd = nums.iter().filter(|n| *n % 2 == 1).count();
let even = nums.iter().filter(|n| *n % 2 == 0).count();
odd > even
}
2026-09-17 03:52:43 +03:00
}
#[derive(Clone)]
struct SpawnInfo {
job_id: String,
idx: usize,
path: PathBuf,
settings: Option<PrintSettings>,
2026-09-17 18:59:00 +03:00
is_second_pass: bool,
2026-09-17 03:52:43 +03:00
}
type JobStore = Arc<Mutex<HashMap<String, Job>>>;
static JOB_COUNTER: AtomicU64 = AtomicU64::new(1);
// ─────────────────────────────── main ───────────────────────────────
2026-09-18 21:30:19 +03:00
2026-09-05 01:26:17 +03:00
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_target(true)
.with_thread_ids(true)
.with_file(true)
.with_line_number(true)
.init();
2026-09-05 01:26:17 +03:00
2026-09-17 03:52:43 +03:00
let store: JobStore = Arc::new(Mutex::new(HashMap::new()));
2026-09-05 01:26:17 +03:00
let cors = CorsLayer::permissive();
let app = Router::new()
2026-09-18 03:23:34 +03:00
.route("/version", get(get_version))
2026-09-17 03:52:43 +03:00
.route("/print", post(create_print))
.route("/print/{job_id}", get(job_status))
.route("/print/{job_id}/advance", post(job_advance))
.route("/print/{job_id}/cancel", post(job_cancel))
2026-09-18 20:25:02 +03:00
.layer(DefaultBodyLimit::max(50 * 1024 * 1024))
2026-09-17 03:52:43 +03:00
.layer(cors)
.with_state(store);
2026-09-05 01:26:17 +03:00
info!("🖨️ Print API запущен на http://0.0.0.0:3000");
2026-09-05 01:26:17 +03:00
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.expect("Не удалось забиндить порт 3000");
axum::serve(listener, app).await.expect("Ошибка сервера");
2026-09-05 01:26:17 +03:00
}
2026-09-17 03:52:43 +03:00
// ─────────────────────────── Обработчики ───────────────────────────
2026-09-18 21:30:19 +03:00
2026-09-17 03:52:43 +03:00
async fn create_print(
State(store): State<JobStore>,
mut multipart: Multipart,
) -> Result<Json<JobView>, (StatusCode, String)> {
2026-09-18 20:25:02 +03:00
info!("📥 Получен запрос на печать");
2026-09-18 21:30:19 +03:00
let mut settings_list: Vec<Option<PrintSettings>> = Vec::new();
2026-09-05 01:26:17 +03:00
let mut files_data: Vec<(String, bytes::Bytes)> = Vec::new();
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let field_name = field.name().unwrap_or("").to_string();
2026-09-05 01:26:17 +03:00
if field_name == "settings" {
let parsed = match field.text().await {
Ok(text) => serde_json::from_str::<PrintSettings>(&text).ok(),
Err(_) => None,
};
// Пушим даже при ошибке парсинга (None), чтобы индексы не съезжали
settings_list.push(parsed);
2026-09-05 01:26:17 +03:00
} else if field_name == "files" {
let file_name = field.file_name().unwrap_or("unknown").to_string();
2026-09-18 20:25:02 +03:00
if let Ok(data) = field.bytes().await {
files_data.push((file_name, data));
2026-09-05 01:26:17 +03:00
}
}
}
if files_data.is_empty() {
2026-09-18 20:25:02 +03:00
return Err((StatusCode::BAD_REQUEST, "Нет файлов".into()));
}
2026-09-17 03:52:43 +03:00
let mut job_files: Vec<JobFile> = Vec::new();
for (i, (file_name, data)) in files_data.into_iter().enumerate() {
2026-09-18 21:30:19 +03:00
let ext = Path::new(&file_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
2026-09-18 21:30:19 +03:00
let temp = if ext.is_empty() {
NamedTempFile::new()
} else {
NamedTempFile::with_suffix(&format!(".{}", ext))
};
2026-09-18 20:25:02 +03:00
if let Ok(temp) = temp {
let path = temp.into_temp_path();
if fs::write(&*path, &data).await.is_ok() {
// i-й файл ↔ i-й набор настроек — имена могут совпадать
let settings = settings_list.get(i).cloned().flatten();
2026-09-18 20:25:02 +03:00
job_files.push(JobFile {
name: file_name,
path,
settings,
status: FileStatus::Queued,
error: None,
});
2026-09-05 01:26:17 +03:00
}
}
2026-09-17 03:52:43 +03:00
}
2026-09-17 03:52:43 +03:00
if job_files.is_empty() {
2026-09-18 20:25:02 +03:00
return Err((StatusCode::BAD_REQUEST, "Не удалось сохранить файлы".into()));
2026-09-17 03:52:43 +03:00
}
2026-09-17 03:52:43 +03:00
let job_id = format!("job-{}", JOB_COUNTER.fetch_add(1, Ordering::SeqCst));
let job = Job {
id: job_id.clone(),
files: job_files,
current: None,
phase: JobPhase::WaitingStart,
cups_id: None,
2026-09-18 21:30:19 +03:00
duplex_first_pass_done: false,
2026-09-17 03:52:43 +03:00
};
2026-09-18 21:30:19 +03:00
2026-09-17 03:52:43 +03:00
let view = job.view();
store.lock().unwrap().insert(job_id.clone(), job);
2026-09-17 03:52:43 +03:00
Ok(Json(view))
}
2026-09-17 03:52:43 +03:00
async fn job_status(
State(store): State<JobStore>,
AxumPath(job_id): AxumPath<String>,
) -> Result<Json<JobView>, (StatusCode, String)> {
let m = store.lock().unwrap();
m.get(&job_id)
.map(|j| Json(j.view()))
2026-09-18 20:25:02 +03:00
.ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into()))
2026-09-17 03:52:43 +03:00
}
2026-09-17 03:52:43 +03:00
async fn job_advance(
State(store): State<JobStore>,
AxumPath(job_id): AxumPath<String>,
) -> Result<Json<JobView>, (StatusCode, String)> {
let (view, spawn_info) = {
let mut m = store.lock().unwrap();
2026-09-17 18:59:00 +03:00
let job = m.get_mut(&job_id)
2026-09-18 20:25:02 +03:00
.ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into()))?;
2026-09-18 21:30:19 +03:00
2026-09-17 03:52:43 +03:00
let si = match job.phase {
2026-09-18 21:30:19 +03:00
// ── Старт: первый файл ──
2026-09-17 03:52:43 +03:00
JobPhase::WaitingStart => {
2026-09-18 20:25:02 +03:00
let next_idx = 0;
job.current = Some(next_idx);
2026-09-18 21:30:19 +03:00
Some(start_printing_locked(job, next_idx))
}
// ── Пользователь подтвердил «забрал / печать закончена» ──
JobPhase::AwaitingPickup => {
let c = job.current.unwrap_or(0);
if job.duplex_first_pass_done {
// Первый проход дуплекса завершён → просим убрать листы
job.duplex_first_pass_done = false;
job.files[c].status = FileStatus::AwaitingClearOutput;
2026-09-18 20:25:02 +03:00
job.phase = JobPhase::AwaitingClearOutput;
2026-09-17 18:59:00 +03:00
None
2026-09-18 20:25:02 +03:00
} else {
2026-09-18 21:30:19 +03:00
// Обычная печать или второй проход дуплекса → файл готов
job.files[c].status = FileStatus::Done;
begin_next_locked(job, c)
2026-09-17 18:59:00 +03:00
}
}
2026-09-18 21:30:19 +03:00
// ── Пользователь убрал листы из выходного лотка ──
2026-09-18 20:25:02 +03:00
JobPhase::AwaitingClearOutput => {
let c = job.current.unwrap_or(0);
2026-09-18 21:30:19 +03:00
job.files[c].status = FileStatus::AwaitingFlip;
job.phase = JobPhase::AwaitingFlip;
None
2026-09-18 20:25:02 +03:00
}
2026-09-18 21:30:19 +03:00
// ── Пользователь переложил бумагу → запускаем второй проход ──
2026-09-18 20:25:02 +03:00
JobPhase::AwaitingFlip => {
let c = job.current.unwrap();
job.files[c].status = FileStatus::Printing;
job.phase = JobPhase::Printing;
Some(SpawnInfo {
job_id: job.id.clone(),
idx: c,
path: job.files[c].path.to_path_buf(),
settings: job.files[c].settings.clone(),
is_second_pass: true,
})
}
2026-09-18 21:30:19 +03:00
2026-09-17 03:52:43 +03:00
_ => None,
};
2026-09-18 21:30:19 +03:00
2026-09-17 03:52:43 +03:00
(job.view(), si)
};
2026-09-17 18:59:00 +03:00
2026-09-17 03:52:43 +03:00
if let Some(si) = spawn_info {
spawn_print_task(store.clone(), si);
}
Ok(Json(view))
}
2026-09-17 03:52:43 +03:00
async fn job_cancel(
State(store): State<JobStore>,
AxumPath(job_id): AxumPath<String>,
) -> Result<Json<JobView>, (StatusCode, String)> {
let cups_ids: Vec<String> = {
let mut m = store.lock().unwrap();
2026-09-18 20:25:02 +03:00
let job = m.get_mut(&job_id)
.ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into()))?;
2026-09-18 21:30:19 +03:00
2026-09-17 03:52:43 +03:00
if job.phase == JobPhase::Finished || job.phase == JobPhase::Cancelled {
return Ok(Json(job.view()));
}
2026-09-18 20:25:02 +03:00
2026-09-17 03:52:43 +03:00
let ids = job.cups_id.take().into_iter().collect::<Vec<_>>();
for f in job.files.iter_mut() {
2026-09-18 20:25:02 +03:00
if matches!(f.status, FileStatus::Queued | FileStatus::Printing) {
2026-09-17 03:52:43 +03:00
f.status = FileStatus::Cancelled;
2026-09-05 01:26:17 +03:00
}
2026-09-17 03:52:43 +03:00
}
job.phase = JobPhase::Cancelled;
job.current = None;
ids
};
2026-09-18 20:25:02 +03:00
2026-09-17 03:52:43 +03:00
for id in cups_ids {
let cancel_bin = cups_bin("cancel");
let _ = tokio::task::spawn_blocking(move || {
2026-09-18 01:44:06 +03:00
Command::new(cancel_bin).arg(id).env("LC_ALL", "C").output()
2026-09-18 20:25:02 +03:00
}).await;
2026-09-17 03:52:43 +03:00
}
2026-09-18 20:25:02 +03:00
2026-09-17 03:52:43 +03:00
let m = store.lock().unwrap();
Ok(Json(m.get(&job_id).unwrap().view()))
}
2026-09-05 01:26:17 +03:00
2026-09-17 03:52:43 +03:00
// ─────────────────────── Печать: state machine ───────────────────────
2026-09-18 21:30:19 +03:00
2026-09-17 03:52:43 +03:00
fn start_printing_locked(job: &mut Job, idx: usize) -> SpawnInfo {
job.files[idx].status = FileStatus::Printing;
job.current = Some(idx);
job.phase = JobPhase::Printing;
job.cups_id = None;
2026-09-18 21:30:19 +03:00
job.duplex_first_pass_done = false;
2026-09-17 03:52:43 +03:00
SpawnInfo {
job_id: job.id.clone(),
idx,
path: job.files[idx].path.to_path_buf(),
settings: job.files[idx].settings.clone(),
2026-09-17 18:59:00 +03:00
is_second_pass: false,
2026-09-17 03:52:43 +03:00
}
}
fn begin_next_locked(job: &mut Job, from: usize) -> Option<SpawnInfo> {
match (from + 1..job.files.len()).find(|&i| job.files[i].status == FileStatus::Queued) {
2026-09-18 20:25:02 +03:00
Some(i) => {
job.current = Some(i);
2026-09-18 21:30:19 +03:00
Some(start_printing_locked(job, i))
2026-09-18 20:25:02 +03:00
}
2026-09-17 03:52:43 +03:00
None => {
job.phase = JobPhase::Finished;
job.current = None;
None
}
}
}
fn spawn_print_task(store: JobStore, info: SpawnInfo) {
tokio::spawn(async move {
2026-09-17 18:59:00 +03:00
let path = info.path.clone();
2026-09-17 03:52:43 +03:00
let settings = info.settings.clone();
2026-09-18 21:30:19 +03:00
let is_duplex = settings.as_ref()
.map(|s| s.sides.as_deref() == Some("Двусторонняя"))
.unwrap_or(false);
2026-09-17 18:59:00 +03:00
let duplex_pass: Option<&str> = if is_duplex {
if info.is_second_pass { Some("even") } else { Some("odd") }
2026-09-18 21:30:19 +03:00
} else {
None
};
2026-09-17 18:59:00 +03:00
2026-09-18 21:30:19 +03:00
let p = path.clone();
let s = settings.clone();
2026-09-17 18:59:00 +03:00
let dp = duplex_pass.map(|d| d.to_string());
2026-09-18 21:30:19 +03:00
let spool = tokio::task::spawn_blocking(move || {
run_lp(&p, s.as_ref(), dp.as_deref())
}).await;
2026-09-17 18:59:00 +03:00
2026-09-17 03:52:43 +03:00
let cups_id = match spool {
Ok(Ok(id)) => id,
Ok(Err(e)) => return mark_error_and_continue(store, info, e).await,
2026-09-17 18:59:00 +03:00
Err(e) => return mark_error_and_continue(store, info, format!("spawn: {}", e)).await,
2026-09-17 03:52:43 +03:00
};
let mut m = store.lock().unwrap();
2026-09-17 18:59:00 +03:00
let job = match m.get_mut(&info.job_id) { Some(j) => j, None => return };
if job.phase == JobPhase::Cancelled { return; }
if job.files.get(info.idx).map(|f| f.status.clone()) != Some(FileStatus::Printing) { return; }
2026-09-18 20:25:02 +03:00
if !cups_id.is_empty() {
job.cups_id = Some(cups_id);
}
2026-09-17 18:59:00 +03:00
if is_duplex && !info.is_second_pass {
2026-09-18 21:30:19 +03:00
// ── Первый проход дуплекса завершён ──
// Просим пользователя подтвердить что печать закончена
job.files[info.idx].status = FileStatus::AwaitingPickup;
job.phase = JobPhase::AwaitingPickup;
job.duplex_first_pass_done = true;
2026-09-17 18:59:00 +03:00
} else {
2026-09-18 21:30:19 +03:00
// ── Обычная печать или второй проход дуплекса ──
job.files[info.idx].status = FileStatus::AwaitingPickup;
2026-09-17 18:59:00 +03:00
job.phase = JobPhase::AwaitingPickup;
2026-09-18 21:30:19 +03:00
job.duplex_first_pass_done = false;
2026-09-17 03:52:43 +03:00
}
2026-09-18 21:30:19 +03:00
2026-09-18 20:25:02 +03:00
job.cups_id = None;
2026-09-17 03:52:43 +03:00
});
}
async fn mark_error_and_continue(store: JobStore, info: SpawnInfo, msg: String) {
let next = {
let mut m = store.lock().unwrap();
2026-09-18 20:25:02 +03:00
let job = match m.get_mut(&info.job_id) { Some(j) => j, None => return };
if job.phase == JobPhase::Cancelled { return; }
2026-09-18 21:30:19 +03:00
2026-09-17 03:52:43 +03:00
if let Some(f) = job.files.get_mut(info.idx) {
2026-09-18 20:25:02 +03:00
if f.status != FileStatus::Printing { return; }
2026-09-17 03:52:43 +03:00
f.status = FileStatus::Error;
f.error = Some(msg);
}
begin_next_locked(job, info.idx)
};
2026-09-18 21:30:19 +03:00
2026-09-18 20:25:02 +03:00
if let Some(si) = next { spawn_print_task(store, si); }
2026-09-17 03:52:43 +03:00
}
2026-09-18 20:25:02 +03:00
// ─────────────────────────── lp / CUPS ───────────────────────────
2026-09-18 21:30:19 +03:00
2026-09-17 03:52:43 +03:00
fn cups_bin(name: &str) -> std::path::PathBuf {
let candidates = [
2026-09-18 20:25:02 +03:00
format!("/pkg/gnu/cups/bin/{}", name),
format!("/run/current-system/sw/bin/{}", name),
2026-09-17 03:52:43 +03:00
format!("/usr/bin/{}", name),
format!("/usr/local/bin/{}", name),
];
for p in &candidates {
let path = std::path::Path::new(p);
2026-09-18 20:25:02 +03:00
if path.exists() { return path.to_path_buf(); }
2026-09-17 03:52:43 +03:00
}
std::path::PathBuf::from(name)
}
fn normalize_format(format: &str) -> String {
match format.trim() {
"" | "Авто" | "auto" => String::new(),
"10x15" => "10x15cm".to_string(),
other => other.to_string(),
}
}
2026-09-17 18:59:00 +03:00
fn run_lp(path: &Path, settings: Option<&PrintSettings>, duplex_pass: Option<&str>) -> Result<String, String> {
2026-09-17 03:52:43 +03:00
let lp = cups_bin("lp");
let mut cmd = Command::new(&lp);
2026-09-18 21:30:19 +03:00
2026-09-17 03:52:43 +03:00
if let Some(s) = settings {
cmd.arg("-n").arg(s.copies.to_string());
2026-09-18 21:30:19 +03:00
if s.pages != "all" && !s.pages.is_empty() {
cmd.arg("-P").arg(&s.pages);
}
if s.color_mode == "bw" {
cmd.arg("-o").arg("ColorModel=Gray");
}
2026-09-17 03:52:43 +03:00
let fmt = normalize_format(&s.format);
2026-09-18 21:30:19 +03:00
if !fmt.is_empty() {
cmd.arg("-o").arg(format!("PageSize={}", fmt));
}
2026-09-17 18:59:00 +03:00
if s.sides.as_deref() == Some("Двусторонняя") && duplex_pass.is_none() {
2026-09-17 03:52:43 +03:00
cmd.arg("-o").arg("sides=two-sided-long-edge");
}
2026-09-17 18:59:00 +03:00
if let Some(d) = s.dpi.as_deref() {
2026-09-18 21:30:19 +03:00
if !d.is_empty() && d != "Авто" {
cmd.arg("-o").arg(format!("printer-resolution={}dpi", d));
}
2026-09-05 01:26:17 +03:00
}
}
2026-09-17 18:59:00 +03:00
match duplex_pass {
2026-09-18 21:30:19 +03:00
Some("odd") => {
cmd.arg("-o").arg("InputSlot=Upper");
cmd.arg("-o").arg("page-set=odd"); // Нечётные страницы
2026-09-18 21:30:19 +03:00
}
2026-09-17 18:59:00 +03:00
Some("even") => {
2026-09-18 21:30:19 +03:00
cmd.arg("-o").arg("InputSlot=Lower");
cmd.arg("-o").arg("page-set=even"); // Чётные страницы
2026-09-17 18:59:00 +03:00
cmd.arg("-o").arg("outputorder=reverse");
2026-09-18 21:30:19 +03:00
cmd.arg("-o").arg("orientation-requested=6");
}
_ => {
cmd.arg("-o").arg("InputSlot=Upper");
2026-09-17 18:59:00 +03:00
}
}
2026-09-17 03:52:43 +03:00
cmd.arg(path);
2026-09-18 21:30:19 +03:00
let out = cmd.env("LC_ALL", "C").output()
.map_err(|e| format!("lp: {}", e))?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.nth(3)
.unwrap_or("")
.to_string())
2026-09-18 01:44:06 +03:00
}