add progress print page
This commit is contained in:
683
src/main.rs
683
src/main.rs
@ -1,18 +1,27 @@
|
||||
use axum::{
|
||||
extract::{DefaultBodyLimit, Multipart},
|
||||
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::post,
|
||||
Router,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::process::Command;
|
||||
use tempfile::NamedTempFile;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tempfile::{NamedTempFile, TempPath};
|
||||
use tokio::fs;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
// ─────────────────────────────── Модели ───────────────────────────────
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
struct PrintSettings {
|
||||
filename: String,
|
||||
@ -21,8 +30,95 @@ struct PrintSettings {
|
||||
#[serde(rename = "colorMode")]
|
||||
color_mode: String,
|
||||
format: String,
|
||||
#[serde(default)]
|
||||
sides: Option<String>,
|
||||
#[serde(default)]
|
||||
dpi: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum FileStatus {
|
||||
Queued,
|
||||
Printing,
|
||||
Awaiting,
|
||||
Done,
|
||||
Error,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum JobPhase {
|
||||
WaitingStart,
|
||||
Printing,
|
||||
AwaitingPickup,
|
||||
Finished,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct JobFileView {
|
||||
name: String,
|
||||
status: FileStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct JobView {
|
||||
job_id: String,
|
||||
phase: JobPhase,
|
||||
files: Vec<JobFileView>,
|
||||
}
|
||||
|
||||
struct JobFile {
|
||||
name: String,
|
||||
path: TempPath, // живёт, пока жив job → автоочистка temp
|
||||
settings: Option<PrintSettings>,
|
||||
status: FileStatus,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
struct Job {
|
||||
id: String,
|
||||
files: Vec<JobFile>,
|
||||
current: Option<usize>,
|
||||
phase: JobPhase,
|
||||
cups_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Job {
|
||||
fn view(&self) -> JobView {
|
||||
JobView {
|
||||
job_id: self.id.clone(),
|
||||
phase: self.phase.clone(),
|
||||
files: self
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| JobFileView {
|
||||
name: f.name.clone(),
|
||||
status: f.status.clone(),
|
||||
error: f.error.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SpawnInfo {
|
||||
job_id: String,
|
||||
idx: usize,
|
||||
path: PathBuf,
|
||||
settings: Option<PrintSettings>,
|
||||
}
|
||||
|
||||
type JobStore = Arc<Mutex<HashMap<String, Job>>>;
|
||||
static JOB_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
// ─────────────────────────────── main ───────────────────────────────
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
@ -32,280 +128,471 @@ async fn main() {
|
||||
.with_line_number(true)
|
||||
.init();
|
||||
|
||||
let store: JobStore = Arc::new(Mutex::new(HashMap::new()));
|
||||
let cors = CorsLayer::permissive();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/print", post(handle_print))
|
||||
.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))
|
||||
.layer(DefaultBodyLimit::max(50 * 1024 * 1024)) // 50 MB
|
||||
.layer(cors);
|
||||
.layer(cors)
|
||||
.with_state(store);
|
||||
|
||||
info!("🖨️ Print API запущен на http://0.0.0.0:3000");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
|
||||
.await
|
||||
.expect("Не удалось забиндить порт 3000");
|
||||
|
||||
axum::serve(listener, app).await.expect("Ошибка сервера");
|
||||
}
|
||||
|
||||
async fn handle_print(mut multipart: Multipart) -> impl IntoResponse {
|
||||
info!("📥 Получен запрос на печать");
|
||||
// ─────────────────────────── Обработчики ───────────────────────────
|
||||
|
||||
let mut printed_files: Vec<String> = Vec::new();
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
async fn create_print(
|
||||
State(store): State<JobStore>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<JobView>, (StatusCode, String)> {
|
||||
info!("📥 Получен запрос на печать (создание задания)");
|
||||
|
||||
let mut settings_map: std::collections::HashMap<String, PrintSettings> =
|
||||
std::collections::HashMap::new();
|
||||
let mut settings_map: HashMap<String, PrintSettings> = HashMap::new();
|
||||
let mut files_data: Vec<(String, bytes::Bytes)> = Vec::new();
|
||||
|
||||
// ── Парсинг multipart ──────────────────────────────────────────────
|
||||
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
|
||||
let field_name = field.name().unwrap_or("").to_string();
|
||||
|
||||
if field_name == "settings" {
|
||||
match field.text().await {
|
||||
Ok(text) => {
|
||||
info!(raw_settings = %text, "Получены настройки печати");
|
||||
match serde_json::from_str::<PrintSettings>(&text) {
|
||||
Ok(settings) => {
|
||||
info!(
|
||||
filename = %settings.filename,
|
||||
pages = %settings.pages,
|
||||
copies = settings.copies,
|
||||
color_mode = %settings.color_mode,
|
||||
format = %settings.format,
|
||||
"✅ Настройки распарсены"
|
||||
);
|
||||
settings_map.insert(settings.filename.clone(), settings);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(raw_text = %text, error = %e, "❌ Не удалось распарсить settings JSON");
|
||||
errors.push(format!("Ошибка парсинга settings: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "❌ Не удалось прочитать поле settings");
|
||||
errors.push(format!("Ошибка чтения settings: {}", e));
|
||||
Ok(text) => match serde_json::from_str::<PrintSettings>(&text) {
|
||||
Ok(s) => {
|
||||
info!(filename = %s.filename, "✅ Настройки распарсены");
|
||||
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" {
|
||||
let file_name = field.file_name().unwrap_or("unknown").to_string();
|
||||
match field.bytes().await {
|
||||
Ok(data) => {
|
||||
info!(
|
||||
file_name = %file_name,
|
||||
size_bytes = data.len(),
|
||||
"📎 Получен файл"
|
||||
);
|
||||
info!(file_name = %file_name, size_bytes = data.len(), "📎 Получен файл");
|
||||
files_data.push((file_name, data));
|
||||
}
|
||||
Err(e) => {
|
||||
error!(file_name = %file_name, error = %e, "❌ Не удалось прочитать файл");
|
||||
errors.push(format!("Ошибка чтения файла {}: {}", file_name, e));
|
||||
}
|
||||
Err(e) => error!(file_name = %file_name, error = %e, "❌ Ошибка чтения файла"),
|
||||
}
|
||||
} else {
|
||||
warn!(field_name = %field_name, "⚠️ Неизвестное поле в multipart, пропущено");
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
total_files = files_data.len(),
|
||||
total_settings = settings_map.len(),
|
||||
"📊 Итоги парсинга multipart"
|
||||
);
|
||||
|
||||
if files_data.is_empty() {
|
||||
error!("❌ В запросе нет файлов для печати");
|
||||
return (
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"В запросе отсутствуют файлы для печати".to_string(),
|
||||
);
|
||||
"В запросе отсутствуют файлы для печати".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Обработка каждого файла ────────────────────────────────────────
|
||||
// Спулим каждый файл во временный (TempPath живёт вместе с job)
|
||||
let mut job_files: Vec<JobFile> = Vec::new();
|
||||
for (file_name, data) in files_data {
|
||||
info!(file_name = %file_name, "🔄 Начало обработки файла");
|
||||
|
||||
// Нормализация расширения
|
||||
let ext = std::path::Path::new(&file_name)
|
||||
let ext = Path::new(&file_name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
info!(file_name = %file_name, extension = %ext, "Определено расширение файла");
|
||||
|
||||
// Создание временного файла
|
||||
let temp_file = if ext.is_empty() {
|
||||
let temp = if ext.is_empty() {
|
||||
NamedTempFile::new()
|
||||
} else {
|
||||
NamedTempFile::with_suffix(&format!(".{}", ext))
|
||||
};
|
||||
|
||||
let temp_file = match temp_file {
|
||||
let temp = match temp {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
error!(file_name = %file_name, error = %e, "❌ Не удалось создать temp файл");
|
||||
errors.push(format!("Не удалось создать temp файл для {}: {}", file_name, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let path = temp_file.path().to_path_buf();
|
||||
info!(
|
||||
file_name = %file_name,
|
||||
temp_path = %path.display(),
|
||||
"📝 Временный файл создан"
|
||||
);
|
||||
|
||||
// Запись данных во временный файл
|
||||
if let Err(e) = fs::write(&path, &data).await {
|
||||
error!(
|
||||
file_name = %file_name,
|
||||
temp_path = %path.display(),
|
||||
error = %e,
|
||||
"❌ Ошибка записи во временный файл"
|
||||
);
|
||||
errors.push(format!("Ошибка записи {}: {}", file_name, e));
|
||||
let path = temp.into_temp_path();
|
||||
if let Err(e) = fs::write(&*path, &data).await {
|
||||
error!(file_name = %file_name, error = %e, "❌ Ошибка записи во временный файл");
|
||||
continue;
|
||||
}
|
||||
|
||||
info!(
|
||||
file_name = %file_name,
|
||||
temp_path = %path.display(),
|
||||
bytes_written = data.len(),
|
||||
"✅ Файл записан во временную директорию"
|
||||
);
|
||||
|
||||
// Поиск настроек для данного файла
|
||||
let settings = settings_map.get(&file_name).cloned();
|
||||
if settings.is_none() {
|
||||
warn!(
|
||||
file_name = %file_name,
|
||||
"⚠️ Настройки печати не найдены для файла, используются значения по умолчанию"
|
||||
);
|
||||
warn!(file_name = %file_name, "⚠️ Настройки не найдены, печать по умолчанию");
|
||||
}
|
||||
job_files.push(JobFile {
|
||||
name: file_name,
|
||||
path,
|
||||
settings,
|
||||
status: FileStatus::Queued,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Клонируем file_name для использования после spawn_blocking
|
||||
let file_name_for_log = file_name.clone();
|
||||
if job_files.is_empty() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Не удалось сохранить ни один файл".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Формирование и выполнение команды lp
|
||||
let print_result = tokio::task::spawn_blocking(move || {
|
||||
let mut cmd = Command::new("lp");
|
||||
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,
|
||||
};
|
||||
let view = job.view();
|
||||
store.lock().unwrap().insert(job_id.clone(), job);
|
||||
info!(job_id = %job_id, files = view.files.len(), "🆕 Задание создано, ждём старт от клиента");
|
||||
Ok(Json(view))
|
||||
}
|
||||
|
||||
if let Some(ref s) = settings {
|
||||
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()))
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))
|
||||
}
|
||||
|
||||
/// Подтверждение клиента: старт первого файла ИЛИ «документ забран, печатай следующий»
|
||||
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();
|
||||
let job = m
|
||||
.get_mut(&job_id)
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))?;
|
||||
let si = match job.phase {
|
||||
JobPhase::WaitingStart => {
|
||||
info!(job_id = %job_id, "▶️ Клиент дал старт: печать первого файла");
|
||||
Some(start_printing_locked(job, 0))
|
||||
}
|
||||
JobPhase::AwaitingPickup => match job.current.take() {
|
||||
Some(c) => {
|
||||
job.files[c].status = FileStatus::Done;
|
||||
info!(job_id = %job_id, file = %job.files[c].name, "✔️ Пользователь забрал документ");
|
||||
begin_next_locked(job, c)
|
||||
}
|
||||
None => {
|
||||
job.phase = JobPhase::Finished;
|
||||
None
|
||||
}
|
||||
},
|
||||
// printing / finished / cancelled — идемпотентно игнорируем
|
||||
_ => None,
|
||||
};
|
||||
(job.view(), si)
|
||||
};
|
||||
if let Some(si) = spawn_info {
|
||||
spawn_print_task(store.clone(), si);
|
||||
}
|
||||
Ok(Json(view))
|
||||
}
|
||||
|
||||
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();
|
||||
let job = m
|
||||
.get_mut(&job_id)
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))?;
|
||||
if job.phase == JobPhase::Finished || job.phase == JobPhase::Cancelled {
|
||||
return Ok(Json(job.view()));
|
||||
}
|
||||
// собираем ВСЕ известные CUPS id для этого job (обычно один, но на всякий случай)
|
||||
let ids = job.cups_id.take().into_iter().collect::<Vec<_>>();
|
||||
for f in job.files.iter_mut() {
|
||||
if matches!(
|
||||
f.status,
|
||||
FileStatus::Queued | FileStatus::Printing | FileStatus::Awaiting
|
||||
) {
|
||||
f.status = FileStatus::Cancelled;
|
||||
}
|
||||
}
|
||||
job.phase = JobPhase::Cancelled;
|
||||
job.current = None;
|
||||
warn!(job_id = %job_id, "🛑 Задание отменено клиентом");
|
||||
ids
|
||||
};
|
||||
// отменяем каждый известный CUPS-job
|
||||
for id in cups_ids {
|
||||
let cancel_bin = cups_bin("cancel");
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
Command::new(cancel_bin).arg(id).output()
|
||||
})
|
||||
.await;
|
||||
}
|
||||
// также попробуем отменить по всем заданиям с нашего temp-префикса (belt and suspenders)
|
||||
// — но обычно CUPS-id'ов достаточно
|
||||
let m = store.lock().unwrap();
|
||||
Ok(Json(m.get(&job_id).unwrap().view()))
|
||||
}
|
||||
|
||||
// ─────────────────────── Печать: state machine ───────────────────────
|
||||
|
||||
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;
|
||||
SpawnInfo {
|
||||
job_id: job.id.clone(),
|
||||
idx,
|
||||
path: job.files[idx].path.to_path_buf(),
|
||||
settings: job.files[idx].settings.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
Some(i) => Some(start_printing_locked(job, i)),
|
||||
None => {
|
||||
job.phase = JobPhase::Finished;
|
||||
job.current = None;
|
||||
job.cups_id = None;
|
||||
info!(job_id = %job.id, "🎉 Задание полностью завершено");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_print_task(store: JobStore, info: SpawnInfo) {
|
||||
tokio::spawn(async move {
|
||||
let path = info.path.clone();
|
||||
let settings = info.settings.clone();
|
||||
|
||||
// 1) Спулим в CUPS
|
||||
let spool =
|
||||
tokio::task::spawn_blocking(move || run_lp(&path, settings.as_ref())).await;
|
||||
let cups_id = match spool {
|
||||
Ok(Ok(id)) => id,
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
// 2) Ждём физического завершения печати
|
||||
if !cups_id.is_empty() {
|
||||
{
|
||||
let mut m = store.lock().unwrap();
|
||||
match m.get_mut(&info.job_id) {
|
||||
Some(job) if job.phase != JobPhase::Cancelled => {
|
||||
job.cups_id = Some(cups_id.clone())
|
||||
}
|
||||
_ => 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 job = match m.get_mut(&info.job_id) {
|
||||
Some(j) => j,
|
||||
None => return,
|
||||
};
|
||||
if job.phase == JobPhase::Cancelled
|
||||
|| job.files.get(info.idx).map(|f| f.status.clone()) != Some(FileStatus::Printing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
job.files[info.idx].status = FileStatus::Awaiting;
|
||||
job.phase = JobPhase::AwaitingPickup;
|
||||
job.cups_id = None;
|
||||
info!(job_id = %info.job_id, file = %job.files[info.idx].name, "✅ Файл допечатан, ждём подтверждения клиента");
|
||||
});
|
||||
}
|
||||
|
||||
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 mut m = store.lock().unwrap();
|
||||
let job = match m.get_mut(&info.job_id) {
|
||||
Some(j) => j,
|
||||
None => return,
|
||||
};
|
||||
if job.phase == JobPhase::Cancelled {
|
||||
return;
|
||||
}
|
||||
if let Some(f) = job.files.get_mut(info.idx) {
|
||||
if f.status != FileStatus::Printing {
|
||||
return;
|
||||
}
|
||||
f.status = FileStatus::Error;
|
||||
f.error = Some(msg);
|
||||
}
|
||||
begin_next_locked(job, info.idx)
|
||||
};
|
||||
if let Some(si) = next {
|
||||
spawn_print_task(store, si);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── lp / lpstat ───────────────────────────
|
||||
|
||||
/// Ищем бинарник CUPS в известных локациях (NixOS/Guix/стандартный Linux)
|
||||
fn cups_bin(name: &str) -> std::path::PathBuf {
|
||||
let candidates = [
|
||||
format!("/pkg/gnu/cups/bin/{}", name), // Guix
|
||||
format!("/run/current-system/sw/bin/{}", name), // NixOS
|
||||
format!("/usr/bin/{}", name),
|
||||
format!("/usr/local/bin/{}", name),
|
||||
];
|
||||
for p in &candidates {
|
||||
let path = std::path::Path::new(p);
|
||||
if path.exists() {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
}
|
||||
// fallback — надеемся на PATH
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_lp(path: &Path, settings: Option<&PrintSettings>) -> Result<String, String> {
|
||||
let lp = cups_bin("lp");
|
||||
let mut cmd = Command::new(&lp);
|
||||
if let Some(s) = settings {
|
||||
cmd.arg("-n").arg(s.copies.to_string());
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
cmd.arg("-o").arg(format!("PageSize={}", s.format));
|
||||
let fmt = normalize_format(&s.format);
|
||||
if !fmt.is_empty() {
|
||||
cmd.arg("-o").arg(format!("PageSize={}", fmt));
|
||||
}
|
||||
if s.sides.as_deref() == Some("Двусторонняя") {
|
||||
cmd.arg("-o").arg("sides=two-sided-long-edge");
|
||||
}
|
||||
match s.dpi.as_deref() {
|
||||
Some(d) if !d.is_empty() && d != "Авто" => {
|
||||
cmd.arg("-o").arg(format!("printer-resolution={}dpi", d));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
cmd.arg(path);
|
||||
|
||||
cmd.arg(&path);
|
||||
|
||||
// Логируем итоговую команду
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().to_string())
|
||||
.collect();
|
||||
info!(
|
||||
file_name = %file_name,
|
||||
command = format!("lp {}", args.join(" ")),
|
||||
command = format!("{} {}", lp.display(), args.join(" ")),
|
||||
"🖨️ Выполнение команды печати"
|
||||
);
|
||||
|
||||
cmd.output()
|
||||
let out = cmd
|
||||
.output()
|
||||
.map_err(|e| format!("Не удалось запустить lp: {}", e))?;
|
||||
if !out.status.success() {
|
||||
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
||||
}
|
||||
// stdout: "request id is PRINTER-123 (1 file(s))"
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
Ok(stdout
|
||||
.split_whitespace()
|
||||
.nth(3)
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
fn lpstat_raw(which: &str) -> Option<String> {
|
||||
let lpstat = cups_bin("lpstat");
|
||||
Command::new(&lpstat)
|
||||
.arg("-W")
|
||||
.arg(which)
|
||||
.arg("-o")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).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))
|
||||
})
|
||||
.await;
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// Обработка результата — используем file_name_for_log вместо file_name
|
||||
match print_result {
|
||||
Ok(Ok(output)) if output.status.success() => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
info!(
|
||||
file_name = %file_name_for_log,
|
||||
stdout = %stdout.trim(),
|
||||
"✅ Файл успешно отправлен на печать"
|
||||
/// Ждём, пока CUPS-job исчезнет из не-завершённых.
|
||||
/// «Не завершён» = всё, что в состоянии queued / active / held (принтер не ready).
|
||||
fn wait_for_cups(id: &str) -> Result<(), String> {
|
||||
let lpstat_path = cups_bin("lpstat");
|
||||
// Если lpstat физически отсутствует — не можем отслеживать, возвращаем ошибку
|
||||
if !lpstat_path.exists() && lpstat_path.to_str().unwrap_or("") == "lpstat" {
|
||||
// пробуем найти через which
|
||||
if Command::new("which")
|
||||
.arg("lpstat")
|
||||
.output()
|
||||
.map(|o| !o.status.success())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return Err(
|
||||
"lpstat не найден — не могу отслеживать статус печати в CUPS".into(),
|
||||
);
|
||||
printed_files.push(file_name_for_log);
|
||||
}
|
||||
Ok(Ok(output)) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
error!(
|
||||
file_name = %file_name_for_log,
|
||||
exit_code = ?output.status.code(),
|
||||
stderr = %stderr.trim(),
|
||||
stdout = %stdout.trim(),
|
||||
"❌ Команда lp завершилась с ошибкой"
|
||||
);
|
||||
errors.push(format!(
|
||||
"lp ошибка для {}: {}",
|
||||
file_name_for_log,
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
error!(
|
||||
file_name = %file_name_for_log,
|
||||
error = %e,
|
||||
"❌ Не удалось запустить процесс lp"
|
||||
);
|
||||
errors.push(format!(
|
||||
"Не удалось запустить lp для {}: {}",
|
||||
file_name_for_log, e
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
file_name = %file_name_for_log,
|
||||
error = %e,
|
||||
"❌ Ошибка join blocking task"
|
||||
);
|
||||
errors.push(format!(
|
||||
"Ошибка выполнения задачи для {}: {}",
|
||||
file_name_for_log, e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Формирование ответа ────────────────────────────────────────────
|
||||
info!(
|
||||
printed_count = printed_files.len(),
|
||||
error_count = errors.len(),
|
||||
"📋 Итоговый результат обработки запроса"
|
||||
);
|
||||
let started = Instant::now();
|
||||
let timeout = Duration::from_secs(30 * 60); // 30 минут максимум
|
||||
|
||||
if errors.is_empty() && !printed_files.is_empty() {
|
||||
info!(files = ?printed_files, "🎉 Все файлы успешно напечатаны");
|
||||
(
|
||||
StatusCode::OK,
|
||||
format!("Напечатано {} файлов", printed_files.len()),
|
||||
)
|
||||
} else if printed_files.is_empty() {
|
||||
error!(errors = ?errors, "💥 Все файлы не удалось напечатать");
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Ошибки: {:?}", errors),
|
||||
)
|
||||
} else {
|
||||
warn!(
|
||||
printed = ?printed_files,
|
||||
errors = ?errors,
|
||||
"⚠️ Частичная печать: некоторые файлы не напечатаны"
|
||||
);
|
||||
(
|
||||
StatusCode::MULTI_STATUS,
|
||||
format!("Частично: {:?}, Ошибки: {:?}", printed_files, errors),
|
||||
)
|
||||
// Первый опрос: проверяем что lpstat вообще работает
|
||||
let probe = lpstat_raw("not-completed");
|
||||
if probe.is_none() {
|
||||
return Err("lpstat не отвечает (возможно, не запущен cupsd)".into());
|
||||
}
|
||||
|
||||
loop {
|
||||
// "not-completed" = все задания, которые ещё не finished/cancelled
|
||||
// Сюда попадают: pending, held, processing, а также "not ready" задания
|
||||
let not_completed = lpstat_raw("not-completed");
|
||||
let in_queue = has_job_in(¬_completed, id);
|
||||
|
||||
if !in_queue {
|
||||
// Либо допечаталось (в completed), либо отменили — в любом случае наша работа сделана
|
||||
info!(cups_id = %id, "✔ CUPS-задание покинуло очередь (допечатано/отменено)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if started.elapsed() > timeout {
|
||||
return Err("Таймаут ожидания завершения печати (30 мин)".into());
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(1500));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user