This commit is contained in:
2026-09-18 20:25:02 +03:00
parent 78477e9400
commit 05dd345205

View File

@ -5,7 +5,6 @@ use axum::{
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
collections::HashMap, collections::HashMap,
@ -15,23 +14,20 @@ use std::{
atomic::{AtomicU64, Ordering}, atomic::{AtomicU64, Ordering},
Arc, Mutex, Arc, Mutex,
}, },
time::{Duration, Instant},
}; };
use tempfile::{NamedTempFile, TempPath}; use tempfile::{NamedTempFile, TempPath};
use tokio::fs; use tokio::fs;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use tracing::{error, info, warn}; use tracing::info;
async fn get_version() -> impl IntoResponse { async fn get_version() -> impl IntoResponse {
Json(serde_json::json!({ Json(serde_json::json!({
"version": "1.0.4", "version": "1.0.5",
"timestamp": chrono::Utc::now().timestamp() "timestamp": chrono::Utc::now().timestamp()
})) }))
} }
// ─────────────────────────────── Модели ─────────────────────────────── // ─────────────────────────────── Модели ───────────────────────────────
#[derive(Deserialize, Clone, Debug)] #[derive(Deserialize, Clone, Debug)]
struct PrintSettings { struct PrintSettings {
filename: String, filename: String,
@ -51,8 +47,9 @@ struct PrintSettings {
enum FileStatus { enum FileStatus {
Queued, Queued,
Printing, Printing,
Sent,
//AwaitingClearOutput,
AwaitingFlip, AwaitingFlip,
Awaiting,
Done, Done,
Error, Error,
Cancelled, Cancelled,
@ -63,6 +60,7 @@ enum FileStatus {
enum JobPhase { enum JobPhase {
WaitingStart, WaitingStart,
Printing, Printing,
AwaitingClearOutput,
AwaitingFlip, AwaitingFlip,
AwaitingPickup, AwaitingPickup,
Finished, Finished,
@ -75,7 +73,6 @@ struct JobFileView {
status: FileStatus, status: FileStatus,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>, error: Option<String>,
/// true → нечётное кол-во страниц, нужно убрать верхний лист перед 2-м проходом
#[serde(default)] #[serde(default)]
remove_top_sheet: bool, remove_top_sheet: bool,
} }
@ -89,7 +86,7 @@ struct JobView {
struct JobFile { struct JobFile {
name: String, name: String,
path: TempPath, // живёт, пока жив job → автоочистка temp path: TempPath,
settings: Option<PrintSettings>, settings: Option<PrintSettings>,
status: FileStatus, status: FileStatus,
error: Option<String>, error: Option<String>,
@ -119,11 +116,10 @@ impl Job {
} }
} }
/// Нечетных страниц больше, чем чётных → лишний лист сверху
fn should_remove_top_sheet(settings: &Option<PrintSettings>) -> bool { fn should_remove_top_sheet(settings: &Option<PrintSettings>) -> bool {
let Some(s) = settings else { return false }; let Some(s) = settings else { return false };
if s.sides.as_deref() != Some("Двусторонняя") { return false; } if s.sides.as_deref() != Some("Двусторонняя") { return false; }
if s.pages == "all" { return false; } // для офисных не знаем кол-во if s.pages == "all" { return false; }
let nums: Vec<u32> = s.pages.split(',') let nums: Vec<u32> = s.pages.split(',')
.filter_map(|p| p.trim().parse().ok()) .filter_map(|p| p.trim().parse().ok())
.collect(); .collect();
@ -146,7 +142,6 @@ type JobStore = Arc<Mutex<HashMap<String, Job>>>;
static JOB_COUNTER: AtomicU64 = AtomicU64::new(1); static JOB_COUNTER: AtomicU64 = AtomicU64::new(1);
// ─────────────────────────────── main ─────────────────────────────── // ─────────────────────────────── main ───────────────────────────────
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::fmt() tracing_subscriber::fmt()
@ -165,7 +160,7 @@ async fn main() {
.route("/print/{job_id}", get(job_status)) .route("/print/{job_id}", get(job_status))
.route("/print/{job_id}/advance", post(job_advance)) .route("/print/{job_id}/advance", post(job_advance))
.route("/print/{job_id}/cancel", post(job_cancel)) .route("/print/{job_id}/cancel", post(job_cancel))
.layer(DefaultBodyLimit::max(50 * 1024 * 1024)) // 50 MB .layer(DefaultBodyLimit::max(50 * 1024 * 1024))
.layer(cors) .layer(cors)
.with_state(store); .with_state(store);
@ -177,79 +172,43 @@ async fn main() {
} }
// ─────────────────────────── Обработчики ─────────────────────────── // ─────────────────────────── Обработчики ───────────────────────────
async fn create_print( async fn create_print(
State(store): State<JobStore>, State(store): State<JobStore>,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Json<JobView>, (StatusCode, String)> { ) -> Result<Json<JobView>, (StatusCode, String)> {
info!("📥 Получен запрос на печать (создание задания)"); info!("📥 Получен запрос на печать");
let mut settings_map: HashMap<String, PrintSettings> = HashMap::new(); let mut settings_map: HashMap<String, PrintSettings> = HashMap::new();
let mut files_data: Vec<(String, bytes::Bytes)> = Vec::new(); let mut files_data: Vec<(String, bytes::Bytes)> = Vec::new();
while let Some(field) = multipart.next_field().await.unwrap_or(None) { while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let field_name = field.name().unwrap_or("").to_string(); let field_name = field.name().unwrap_or("").to_string();
if field_name == "settings" { if field_name == "settings" {
match field.text().await { if let Ok(text) = field.text().await {
Ok(text) => match serde_json::from_str::<PrintSettings>(&text) { if let Ok(s) = serde_json::from_str::<PrintSettings>(&text) {
Ok(s) => {
info!(filename = %s.filename, "✅ Настройки распарсены");
settings_map.insert(s.filename.clone(), s); settings_map.insert(s.filename.clone(), s);
} }
Err(e) => error!(raw = %text, error = %e, "Не удалось распарсить settings"),
},
Err(e) => error!(error = %e, "❌ Ошибка чтения поля settings"),
} }
} else if field_name == "files" { } else if field_name == "files" {
let file_name = field.file_name().unwrap_or("unknown").to_string(); let file_name = field.file_name().unwrap_or("unknown").to_string();
match field.bytes().await { if let Ok(data) = field.bytes().await {
Ok(data) => {
info!(file_name = %file_name, size_bytes = data.len(), "📎 Получен файл");
files_data.push((file_name, data)); files_data.push((file_name, data));
} }
Err(e) => error!(file_name = %file_name, error = %e, "❌ Ошибка чтения файла"),
}
} else {
warn!(field_name = %field_name, "⚠️ Неизвестное поле в multipart, пропущено");
} }
} }
if files_data.is_empty() { if files_data.is_empty() {
return Err(( return Err((StatusCode::BAD_REQUEST, "Нет файлов".into()));
StatusCode::BAD_REQUEST,
"В запросе отсутствуют файлы для печати".into(),
));
} }
// Спулим каждый файл во временный (TempPath живёт вместе с job)
let mut job_files: Vec<JobFile> = Vec::new(); let mut job_files: Vec<JobFile> = Vec::new();
for (file_name, data) in files_data { for (file_name, data) in files_data {
let ext = Path::new(&file_name) let ext = Path::new(&file_name).extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
.extension() let temp = if ext.is_empty() { NamedTempFile::new() } else { NamedTempFile::with_suffix(&format!(".{}", ext)) };
.and_then(|e| e.to_str()) if let Ok(temp) = temp {
.unwrap_or("")
.to_lowercase();
let temp = if ext.is_empty() {
NamedTempFile::new()
} else {
NamedTempFile::with_suffix(&format!(".{}", ext))
};
let temp = match temp {
Ok(f) => f,
Err(e) => {
error!(file_name = %file_name, error = %e, "Не удалось создать temp файл");
continue;
}
};
let path = temp.into_temp_path(); let path = temp.into_temp_path();
if let Err(e) = fs::write(&*path, &data).await { if fs::write(&*path, &data).await.is_ok() {
error!(file_name = %file_name, error = %e, "❌ Ошибка записи во временный файл");
continue;
}
let settings = settings_map.get(&file_name).cloned(); let settings = settings_map.get(&file_name).cloned();
if settings.is_none() {
warn!(file_name = %file_name, "⚠️ Настройки не найдены, печать по умолчанию");
}
job_files.push(JobFile { job_files.push(JobFile {
name: file_name, name: file_name,
path, path,
@ -258,12 +217,11 @@ async fn create_print(
error: None, error: None,
}); });
} }
}
}
if job_files.is_empty() { if job_files.is_empty() {
return Err(( return Err((StatusCode::BAD_REQUEST, "Не удалось сохранить файлы".into()));
StatusCode::BAD_REQUEST,
"Не удалось сохранить ни один файл".into(),
));
} }
let job_id = format!("job-{}", JOB_COUNTER.fetch_add(1, Ordering::SeqCst)); let job_id = format!("job-{}", JOB_COUNTER.fetch_add(1, Ordering::SeqCst));
@ -276,7 +234,6 @@ async fn create_print(
}; };
let view = job.view(); let view = job.view();
store.lock().unwrap().insert(job_id.clone(), job); store.lock().unwrap().insert(job_id.clone(), job);
info!(job_id = %job_id, files = view.files.len(), "🆕 Задание создано, ждём старт от клиента");
Ok(Json(view)) Ok(Json(view))
} }
@ -287,10 +244,9 @@ async fn job_status(
let m = store.lock().unwrap(); let m = store.lock().unwrap();
m.get(&job_id) m.get(&job_id)
.map(|j| Json(j.view())) .map(|j| Json(j.view()))
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id))) .ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into()))
} }
/// Подтверждение клиента: старт первого файла ИЛИ «документ забран, печатай следующий»
async fn job_advance( async fn job_advance(
State(store): State<JobStore>, State(store): State<JobStore>,
AxumPath(job_id): AxumPath<String>, AxumPath(job_id): AxumPath<String>,
@ -298,21 +254,27 @@ async fn job_advance(
let (view, spawn_info) = { let (view, spawn_info) = {
let mut m = store.lock().unwrap(); let mut m = store.lock().unwrap();
let job = m.get_mut(&job_id) let job = m.get_mut(&job_id)
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))?; .ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into()))?;
let si = match job.phase { let si = match job.phase {
JobPhase::WaitingStart => { JobPhase::WaitingStart => {
info!(job_id = %job_id, "▶️ Старт первого файла"); let next_idx = 0;
Some(start_printing_locked(job, 0)) job.current = Some(next_idx);
if is_duplex_and_needs_clear(job, next_idx) {
job.phase = JobPhase::AwaitingClearOutput;
None
} else {
Some(start_printing_locked(job, next_idx))
}
}
JobPhase::AwaitingClearOutput => {
let c = job.current.unwrap_or(0);
Some(start_printing_locked(job, c))
} }
JobPhase::AwaitingFlip => { JobPhase::AwaitingFlip => {
if let Some(c) = job.current { let c = job.current.unwrap();
info!(job_id = %job_id, file = %job.files[c].name,
"🔄 Запуск второго прохода (чётные страницы)");
job.files[c].status = FileStatus::Printing; job.files[c].status = FileStatus::Printing;
job.phase = JobPhase::Printing; job.phase = JobPhase::Printing;
job.cups_id = None;
Some(SpawnInfo { Some(SpawnInfo {
job_id: job.id.clone(), job_id: job.id.clone(),
idx: c, idx: c,
@ -320,20 +282,14 @@ async fn job_advance(
settings: job.files[c].settings.clone(), settings: job.files[c].settings.clone(),
is_second_pass: true, is_second_pass: true,
}) })
} else {
None
} }
}
JobPhase::AwaitingPickup => match job.current.take() { JobPhase::AwaitingPickup => match job.current.take() {
Some(c) => { Some(c) => {
job.files[c].status = FileStatus::Done; job.files[c].status = FileStatus::Done;
info!(job_id = %job_id, file = %job.files[c].name, "✔️ Документ забран");
begin_next_locked(job, c) begin_next_locked(job, c)
} }
None => { job.phase = JobPhase::Finished; None } None => { job.phase = JobPhase::Finished; None }
}, },
_ => None, _ => None,
}; };
(job.view(), si) (job.view(), si)
@ -351,43 +307,36 @@ async fn job_cancel(
) -> Result<Json<JobView>, (StatusCode, String)> { ) -> Result<Json<JobView>, (StatusCode, String)> {
let cups_ids: Vec<String> = { let cups_ids: Vec<String> = {
let mut m = store.lock().unwrap(); let mut m = store.lock().unwrap();
let job = m let job = m.get_mut(&job_id)
.get_mut(&job_id) .ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into()))?;
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))?;
if job.phase == JobPhase::Finished || job.phase == JobPhase::Cancelled { if job.phase == JobPhase::Finished || job.phase == JobPhase::Cancelled {
return Ok(Json(job.view())); return Ok(Json(job.view()));
} }
// собираем ВСЕ известные CUPS id для этого job (обычно один, но на всякий случай)
let ids = job.cups_id.take().into_iter().collect::<Vec<_>>(); let ids = job.cups_id.take().into_iter().collect::<Vec<_>>();
for f in job.files.iter_mut() { for f in job.files.iter_mut() {
if matches!( if matches!(f.status, FileStatus::Queued | FileStatus::Printing) {
f.status,
FileStatus::Queued | FileStatus::Printing | FileStatus::Awaiting
) {
f.status = FileStatus::Cancelled; f.status = FileStatus::Cancelled;
} }
} }
job.phase = JobPhase::Cancelled; job.phase = JobPhase::Cancelled;
job.current = None; job.current = None;
warn!(job_id = %job_id, "🛑 Задание отменено клиентом");
ids ids
}; };
// отменяем каждый известный CUPS-job
for id in cups_ids { for id in cups_ids {
let cancel_bin = cups_bin("cancel"); let cancel_bin = cups_bin("cancel");
let _ = tokio::task::spawn_blocking(move || { let _ = tokio::task::spawn_blocking(move || {
Command::new(cancel_bin).arg(id).env("LC_ALL", "C").output() Command::new(cancel_bin).arg(id).env("LC_ALL", "C").output()
}) }).await;
.await;
} }
// также попробуем отменить по всем заданиям с нашего temp-префикса (belt and suspenders)
// — но обычно CUPS-id'ов достаточно
let m = store.lock().unwrap(); let m = store.lock().unwrap();
Ok(Json(m.get(&job_id).unwrap().view())) Ok(Json(m.get(&job_id).unwrap().view()))
} }
// ─────────────────────── Печать: state machine ─────────────────────── // ─────────────────────── Печать: state machine ───────────────────────
fn start_printing_locked(job: &mut Job, idx: usize) -> SpawnInfo { fn start_printing_locked(job: &mut Job, idx: usize) -> SpawnInfo {
job.files[idx].status = FileStatus::Printing; job.files[idx].status = FileStatus::Printing;
job.current = Some(idx); job.current = Some(idx);
@ -404,39 +353,44 @@ fn start_printing_locked(job: &mut Job, idx: usize) -> SpawnInfo {
fn begin_next_locked(job: &mut Job, from: usize) -> Option<SpawnInfo> { 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) { match (from + 1..job.files.len()).find(|&i| job.files[i].status == FileStatus::Queued) {
Some(i) => Some(start_printing_locked(job, i)), Some(i) => {
job.current = Some(i);
if is_duplex_and_needs_clear(job, i) {
job.phase = JobPhase::AwaitingClearOutput;
None
} else {
Some(start_printing_locked(job, i))
}
}
None => { None => {
job.phase = JobPhase::Finished; job.phase = JobPhase::Finished;
job.current = None; job.current = None;
job.cups_id = None;
info!(job_id = %job.id, "🎉 Задание полностью завершено");
None None
} }
} }
} }
fn is_duplex_and_needs_clear(job: &Job, next_idx: usize) -> bool {
let next_file = &job.files[next_idx];
let is_duplex = next_file.settings.as_ref()
.map(|s| s.sides.as_deref() == Some("Двусторонняя"))
.unwrap_or(false);
is_duplex && next_idx > 0
}
fn spawn_print_task(store: JobStore, info: SpawnInfo) { fn spawn_print_task(store: JobStore, info: SpawnInfo) {
tokio::spawn(async move { tokio::spawn(async move {
let path = info.path.clone(); let path = info.path.clone();
let settings = info.settings.clone(); let settings = info.settings.clone();
let is_duplex = settings.as_ref().map(|s| s.sides.as_deref() == Some("Двусторонняя")).unwrap_or(false);
let is_duplex = settings.as_ref()
.map(|s| s.sides.as_deref() == Some("Двусторонняя"))
.unwrap_or(false);
let duplex_pass: Option<&str> = if is_duplex { let duplex_pass: Option<&str> = if is_duplex {
if info.is_second_pass { Some("even") } else { Some("odd") } if info.is_second_pass { Some("even") } else { Some("odd") }
} else { } else { None };
None
};
// 1) Спулим в CUPS
let p = path.clone(); let p = path.clone();
let s = settings.clone(); let s = settings.clone();
let dp = duplex_pass.map(|d| d.to_string()); let dp = duplex_pass.map(|d| d.to_string());
let spool = tokio::task::spawn_blocking(move || { let spool = tokio::task::spawn_blocking(move || run_lp(&p, s.as_ref(), dp.as_deref())).await;
run_lp(&p, s.as_ref(), dp.as_deref())
}).await;
let cups_id = match spool { let cups_id = match spool {
Ok(Ok(id)) => id, Ok(Ok(id)) => id,
@ -444,92 +398,53 @@ fn spawn_print_task(store: JobStore, info: SpawnInfo) {
Err(e) => return mark_error_and_continue(store, info, format!("spawn: {}", e)).await, Err(e) => return mark_error_and_continue(store, info, format!("spawn: {}", e)).await,
}; };
// 2) Ждём физического завершения
if !cups_id.is_empty() {
{
let mut m = store.lock().unwrap();
if let Some(job) = m.get_mut(&info.job_id) {
if job.phase != JobPhase::Cancelled {
job.cups_id = Some(cups_id.clone());
} else { return; }
} else { return; }
}
let cid = cups_id.clone();
let wait = tokio::task::spawn_blocking(move || wait_for_cups(&cid)).await;
match wait {
Ok(Ok(())) => {}
Ok(Err(e)) => return mark_error_and_continue(store, info, e).await,
Err(e) => return mark_error_and_continue(store, info, format!("spawn: {}", e)).await,
}
}
// 3) Обновляем статус
let mut m = store.lock().unwrap(); let mut m = store.lock().unwrap();
let job = match m.get_mut(&info.job_id) { Some(j) => j, None => return }; let job = match m.get_mut(&info.job_id) { Some(j) => j, None => return };
if job.phase == JobPhase::Cancelled { return; } if job.phase == JobPhase::Cancelled { return; }
if job.files.get(info.idx).map(|f| f.status.clone()) != Some(FileStatus::Printing) { return; } if job.files.get(info.idx).map(|f| f.status.clone()) != Some(FileStatus::Printing) { return; }
if !cups_id.is_empty() {
job.cups_id = Some(cups_id);
}
if is_duplex && !info.is_second_pass { if is_duplex && !info.is_second_pass {
// Первый проход завершён → ждём, пока пользователь переложит бумагу
job.files[info.idx].status = FileStatus::AwaitingFlip; job.files[info.idx].status = FileStatus::AwaitingFlip;
job.phase = JobPhase::AwaitingFlip; job.phase = JobPhase::AwaitingFlip;
job.cups_id = None;
info!(job_id = %info.job_id, file = %job.files[info.idx].name,
"🔄 Первый проход завершён, ждём переворота бумаги");
} else { } else {
// Обычная печать или второй проход дуплекса → ждём, пока заберут job.files[info.idx].status = FileStatus::Sent;
job.files[info.idx].status = FileStatus::Awaiting;
job.phase = JobPhase::AwaitingPickup; job.phase = JobPhase::AwaitingPickup;
job.cups_id = None;
info!(job_id = %info.job_id, file = %job.files[info.idx].name,
"✅ Печать завершена, ждём подтверждения");
} }
job.cups_id = None;
}); });
} }
async fn mark_error_and_continue(store: JobStore, info: SpawnInfo, msg: String) { async fn mark_error_and_continue(store: JobStore, info: SpawnInfo, msg: String) {
error!(job_id = %info.job_id, idx = info.idx, error = %msg, "❌ Ошибка печати файла");
let next = { let next = {
let mut m = store.lock().unwrap(); let mut m = store.lock().unwrap();
let job = match m.get_mut(&info.job_id) { let job = match m.get_mut(&info.job_id) { Some(j) => j, None => return };
Some(j) => j, if job.phase == JobPhase::Cancelled { return; }
None => return,
};
if job.phase == JobPhase::Cancelled {
return;
}
if let Some(f) = job.files.get_mut(info.idx) { if let Some(f) = job.files.get_mut(info.idx) {
if f.status != FileStatus::Printing { if f.status != FileStatus::Printing { return; }
return;
}
f.status = FileStatus::Error; f.status = FileStatus::Error;
f.error = Some(msg); f.error = Some(msg);
} }
begin_next_locked(job, info.idx) begin_next_locked(job, info.idx)
}; };
if let Some(si) = next { if let Some(si) = next { spawn_print_task(store, si); }
spawn_print_task(store, si);
}
} }
// ─────────────────────────── lp / lpstat ─────────────────────────── // ─────────────────────────── lp / CUPS ───────────────────────────
/// Ищем бинарник CUPS в известных локациях (NixOS/Guix/стандартный Linux)
fn cups_bin(name: &str) -> std::path::PathBuf { fn cups_bin(name: &str) -> std::path::PathBuf {
let candidates = [ let candidates = [
format!("/pkg/gnu/cups/bin/{}", name), // Guix format!("/pkg/gnu/cups/bin/{}", name),
format!("/run/current-system/sw/bin/{}", name), // NixOS format!("/run/current-system/sw/bin/{}", name),
format!("/usr/bin/{}", name), format!("/usr/bin/{}", name),
format!("/usr/local/bin/{}", name), format!("/usr/local/bin/{}", name),
]; ];
for p in &candidates { for p in &candidates {
let path = std::path::Path::new(p); let path = std::path::Path::new(p);
if path.exists() { if path.exists() { return path.to_path_buf(); }
return path.to_path_buf();
} }
}
// fallback — надеемся на PATH
std::path::PathBuf::from(name) std::path::PathBuf::from(name)
} }
@ -544,54 +459,30 @@ fn normalize_format(format: &str) -> String {
fn run_lp(path: &Path, settings: Option<&PrintSettings>, duplex_pass: Option<&str>) -> Result<String, String> { fn run_lp(path: &Path, settings: Option<&PrintSettings>, duplex_pass: Option<&str>) -> Result<String, String> {
let lp = cups_bin("lp"); let lp = cups_bin("lp");
let mut cmd = Command::new(&lp); let mut cmd = Command::new(&lp);
if let Some(s) = settings { if let Some(s) = settings {
cmd.arg("-n").arg(s.copies.to_string()); cmd.arg("-n").arg(s.copies.to_string());
if s.pages != "all" && !s.pages.is_empty() { cmd.arg("-P").arg(&s.pages); }
if s.pages != "all" && !s.pages.is_empty() { if s.color_mode == "bw" { cmd.arg("-o").arg("ColorModel=Gray"); }
cmd.arg("-P").arg(&s.pages);
}
if s.color_mode == "bw" {
cmd.arg("-o").arg("ColorModel=Gray");
}
let fmt = normalize_format(&s.format); let fmt = normalize_format(&s.format);
if !fmt.is_empty() { if !fmt.is_empty() { cmd.arg("-o").arg(format!("PageSize={}", fmt)); }
cmd.arg("-o").arg(format!("PageSize={}", fmt));
}
// Аппаратный дуплекс только если НЕ ручная двухпроходная печать
if s.sides.as_deref() == Some("Двусторонняя") && duplex_pass.is_none() { if s.sides.as_deref() == Some("Двусторонняя") && duplex_pass.is_none() {
cmd.arg("-o").arg("sides=two-sided-long-edge"); cmd.arg("-o").arg("sides=two-sided-long-edge");
} }
if let Some(d) = s.dpi.as_deref() { if let Some(d) = s.dpi.as_deref() {
if !d.is_empty() && d != "Авто" { if !d.is_empty() && d != "Авто" { cmd.arg("-o").arg(format!("printer-resolution={}dpi", d)); }
cmd.arg("-o").arg(format!("printer-resolution={}dpi", d));
}
} }
} }
// Выбор лотка (InputSlot) // СЛОТЫ: Upper для обычной, Lower для ручной (дуплекс)
if let Some(pass) = duplex_pass { if let Some(pass) = duplex_pass {
if pass == "even" {
// Второй проход (чётные страницы) — ручная подача
cmd.arg("-o").arg("InputSlot=Manual");
// Поворот на 180° для второго прохода
cmd.arg("-o").arg("orientation-requested=6");
} else {
// Первый проход (нечётные страницы) — нижний лоток
cmd.arg("-o").arg("InputSlot=Lower"); cmd.arg("-o").arg("InputSlot=Lower");
} if pass == "even" { cmd.arg("-o").arg("orientation-requested=6"); }
} else { } else {
// Обычная печать — нижний лоток cmd.arg("-o").arg("InputSlot=Upper");
cmd.arg("-o").arg("InputSlot=Lower");
} }
// Ручной дуплекс: разбивка на чёт/нечёт
match duplex_pass { match duplex_pass {
Some("odd") => { Some("odd") => { cmd.arg("-o").arg("page-set=odd"); }
cmd.arg("-o").arg("page-set=odd");
}
Some("even") => { Some("even") => {
cmd.arg("-o").arg("page-set=even"); cmd.arg("-o").arg("page-set=even");
cmd.arg("-o").arg("outputorder=reverse"); cmd.arg("-o").arg("outputorder=reverse");
@ -600,114 +491,7 @@ fn run_lp(path: &Path, settings: Option<&PrintSettings>, duplex_pass: Option<&st
} }
cmd.arg(path); cmd.arg(path);
let out = cmd.env("LC_ALL", "C").output().map_err(|e| format!("lp: {}", e))?;
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().to_string()).collect(); if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); }
info!(command = format!("{} {}", lp.display(), args.join(" ")), "🖨️ Команда печати"); Ok(String::from_utf8_lossy(&out.stdout).split_whitespace().nth(3).unwrap_or("").to_string())
// ВАЖНО: LC_ALL=C гарантирует, что ответ будет на английском и парсинг ID не сломается
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());
}
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
Ok(stdout.split_whitespace().nth(3).unwrap_or("").to_string())
}
fn has_job_in(output: &Option<String>, id: &str) -> bool {
output
.as_deref()
.map(|s| {
s.lines()
.any(|l| l.split_whitespace().next() == Some(id))
})
.unwrap_or(false)
}
fn lpstat_raw(which: &str) -> Option<String> {
let lpstat = cups_bin("lpstat");
Command::new(&lpstat)
.arg("-W")
.arg(which)
.arg("-o")
.env("LC_ALL", "C")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
}
fn wait_for_cups(id: &str) -> Result<(), String> {
let lpstat_path = cups_bin("lpstat");
if !lpstat_path.exists() && lpstat_path.to_str().unwrap_or("") == "lpstat" {
if Command::new("which")
.arg("lpstat")
.env("LC_ALL", "C")
.output()
.map(|o| !o.status.success())
.unwrap_or(true)
{
return Err(
"lpstat не найден — не могу отслеживать статус печати в CUPS".into(),
);
}
}
let started = Instant::now();
let timeout = Duration::from_secs(30 * 60); // 30 минут максимум
let probe = lpstat_raw("not-completed");
if probe.is_none() {
return Err("lpstat не отвечает (возможно, не запущен cupsd)".into());
}
// Получаем имя принтера по умолчанию для проверки физического статуса
let printer_name = get_default_printer().unwrap_or_else(|| "Auto_HP_P2015".to_string());
loop {
let not_completed = lpstat_raw("not-completed");
let in_queue = has_job_in(&not_completed, id);
if !in_queue {
// Задачи уже нет в очереди CUPS. Проверяем, допечатал ли принтер физически.
if !is_printer_physically_printing(&printer_name) {
info!(cups_id = %id, "✔ CUPS-задание выполнено и принтер физически допечатал");
return Ok(());
} else {
info!(cups_id = %id, "⏳ CUPS передал данные, но принтер всё ещё физически печатает из буфера...");
}
}
if started.elapsed() > timeout {
return Err("Таймаут ожидания завершения печати (30 мин)".into());
}
std::thread::sleep(Duration::from_millis(1500));
}
}
fn get_default_printer() -> Option<String> {
let out = Command::new("lpstat")
.arg("-d")
.env("LC_ALL", "C")
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&out.stdout);
// Формат: "system default destination: Auto_HP_P2015"
stdout.split(':').nth(1).map(|s| s.trim().to_string())
}
fn is_printer_physically_printing(printer_name: &str) -> bool {
if let Ok(out) = Command::new("lpstat")
.arg("-p")
.arg(printer_name)
.env("LC_ALL", "C")
.output()
{
let stdout = String::from_utf8_lossy(&out.stdout);
// "now printing" или "processing" означают, что принтер физически печатает из буфера
if stdout.contains("now printing") || stdout.contains("processing") {
return true;
}
}
false
} }