test
This commit is contained in:
434
src/main.rs
434
src/main.rs
@ -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,93 +172,56 @@ 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) => {
|
settings_map.insert(s.filename.clone(), 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" {
|
} 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) => {
|
files_data.push((file_name, data));
|
||||||
info!(file_name = %file_name, size_bytes = data.len(), "📎 Получен файл");
|
|
||||||
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("")
|
let path = temp.into_temp_path();
|
||||||
.to_lowercase();
|
if fs::write(&*path, &data).await.is_ok() {
|
||||||
let temp = if ext.is_empty() {
|
let settings = settings_map.get(&file_name).cloned();
|
||||||
NamedTempFile::new()
|
|
||||||
} else {
|
job_files.push(JobFile {
|
||||||
NamedTempFile::with_suffix(&format!(".{}", ext))
|
name: file_name,
|
||||||
};
|
path,
|
||||||
let temp = match temp {
|
settings,
|
||||||
Ok(f) => f,
|
status: FileStatus::Queued,
|
||||||
Err(e) => {
|
error: None,
|
||||||
error!(file_name = %file_name, error = %e, "❌ Не удалось создать temp файл");
|
});
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
let path = temp.into_temp_path();
|
|
||||||
if let Err(e) = fs::write(&*path, &data).await {
|
|
||||||
error!(file_name = %file_name, error = %e, "❌ Ошибка записи во временный файл");
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
let settings = settings_map.get(&file_name).cloned();
|
|
||||||
if settings.is_none() {
|
|
||||||
warn!(file_name = %file_name, "⚠️ Настройки не найдены, печать по умолчанию");
|
|
||||||
}
|
|
||||||
job_files.push(JobFile {
|
|
||||||
name: file_name,
|
|
||||||
path,
|
|
||||||
settings,
|
|
||||||
status: FileStatus::Queued,
|
|
||||||
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,42 +254,42 @@ 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;
|
||||||
JobPhase::AwaitingFlip => {
|
|
||||||
if let Some(c) = job.current {
|
|
||||||
info!(job_id = %job_id, file = %job.files[c].name,
|
|
||||||
"🔄 Запуск второго прохода (чётные страницы)");
|
|
||||||
job.files[c].status = FileStatus::Printing;
|
|
||||||
job.phase = JobPhase::Printing;
|
|
||||||
job.cups_id = None;
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
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 => {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
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");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Обычная печать — нижний лоток
|
|
||||||
cmd.arg("-o").arg("InputSlot=Lower");
|
cmd.arg("-o").arg("InputSlot=Lower");
|
||||||
|
if pass == "even" { cmd.arg("-o").arg("orientation-requested=6"); }
|
||||||
|
} else {
|
||||||
|
cmd.arg("-o").arg("InputSlot=Upper");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ручной дуплекс: разбивка на чёт/нечёт
|
|
||||||
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(¬_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
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user