use axum::{ extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, path::{Path, PathBuf}, process::Command, sync::{ atomic::{AtomicU64, Ordering}, Arc, Mutex, }, }; use tempfile::{NamedTempFile, TempPath}; use tokio::fs; use tower_http::cors::CorsLayer; use tracing::info; async fn get_version() -> impl IntoResponse { Json(serde_json::json!({ "version": "1.0.6", "timestamp": chrono::Utc::now().timestamp() })) } // ─────────────────────────────── Модели ─────────────────────────────── #[derive(Deserialize, Clone, Debug)] struct PrintSettings { filename: String, pages: String, copies: u32, #[serde(rename = "colorMode")] color_mode: String, format: String, #[serde(default)] sides: Option, #[serde(default)] dpi: Option, } #[derive(Clone, Debug, Serialize, PartialEq)] #[serde(rename_all = "snake_case")] enum FileStatus { Queued, Printing, Sent, AwaitingClearOutput, AwaitingFlip, AwaitingPickup, Done, Error, Cancelled, } #[derive(Clone, Debug, Serialize, PartialEq)] #[serde(rename_all = "snake_case")] enum JobPhase { WaitingStart, Printing, AwaitingClearOutput, AwaitingFlip, AwaitingPickup, Finished, Cancelled, } #[derive(Clone, Debug, Serialize)] struct JobFileView { name: String, status: FileStatus, #[serde(skip_serializing_if = "Option::is_none")] error: Option, #[serde(default)] remove_top_sheet: bool, } #[derive(Clone, Debug, Serialize)] struct JobView { job_id: String, phase: JobPhase, files: Vec, } struct JobFile { name: String, path: TempPath, settings: Option, status: FileStatus, error: Option, } struct Job { id: String, files: Vec, current: Option, phase: JobPhase, cups_id: Option, /// true если только что завершился ПЕРВЫЙ проход дуплекса duplex_first_pass_done: bool, } 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(), remove_top_sheet: Self::should_remove_top_sheet(&f.settings), } }).collect(), } } 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) } fn should_remove_top_sheet(settings: &Option) -> bool { let Some(s) = settings else { return false }; if s.sides.as_deref() != Some("Двусторонняя") { return false; } if s.pages == "all" { return false; } let nums: Vec = 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 } } #[derive(Clone)] struct SpawnInfo { job_id: String, idx: usize, path: PathBuf, settings: Option, is_second_pass: bool, } type JobStore = Arc>>; static JOB_COUNTER: AtomicU64 = AtomicU64::new(1); // ─────────────────────────────── main ─────────────────────────────── #[tokio::main] async fn main() { tracing_subscriber::fmt() .with_target(true) .with_thread_ids(true) .with_file(true) .with_line_number(true) .init(); let store: JobStore = Arc::new(Mutex::new(HashMap::new())); let cors = CorsLayer::permissive(); let app = Router::new() .route("/version", get(get_version)) .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)) .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 create_print( State(store): State, mut multipart: Multipart, ) -> Result, (StatusCode, String)> { info!("📥 Получен запрос на печать"); let mut settings_map: HashMap = HashMap::new(); 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(); if field_name == "settings" { if let Ok(text) = field.text().await { if let Ok(s) = serde_json::from_str::(&text) { settings_map.insert(s.filename.clone(), s); } } } else if field_name == "files" { let file_name = field.file_name().unwrap_or("unknown").to_string(); if let Ok(data) = field.bytes().await { files_data.push((file_name, data)); } } } if files_data.is_empty() { return Err((StatusCode::BAD_REQUEST, "Нет файлов".into())); } let mut job_files: Vec = Vec::new(); for (file_name, data) in files_data { let ext = Path::new(&file_name) .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); let temp = if ext.is_empty() { NamedTempFile::new() } else { NamedTempFile::with_suffix(&format!(".{}", ext)) }; if let Ok(temp) = temp { let path = temp.into_temp_path(); if fs::write(&*path, &data).await.is_ok() { let settings = settings_map.get(&file_name).cloned(); job_files.push(JobFile { name: file_name, path, settings, status: FileStatus::Queued, error: None, }); } } } if job_files.is_empty() { return Err((StatusCode::BAD_REQUEST, "Не удалось сохранить файлы".into())); } 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, duplex_first_pass_done: false, }; let view = job.view(); store.lock().unwrap().insert(job_id.clone(), job); Ok(Json(view)) } async fn job_status( State(store): State, AxumPath(job_id): AxumPath, ) -> Result, (StatusCode, String)> { let m = store.lock().unwrap(); m.get(&job_id) .map(|j| Json(j.view())) .ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into())) } async fn job_advance( State(store): State, AxumPath(job_id): AxumPath, ) -> Result, (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, "Задание не найдено".into()))?; let si = match job.phase { // ── Старт: первый файл ── JobPhase::WaitingStart => { let next_idx = 0; job.current = Some(next_idx); 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; job.phase = JobPhase::AwaitingClearOutput; None } else { // Обычная печать или второй проход дуплекса → файл готов job.files[c].status = FileStatus::Done; begin_next_locked(job, c) } } // ── Пользователь убрал листы из выходного лотка ── JobPhase::AwaitingClearOutput => { let c = job.current.unwrap_or(0); job.files[c].status = FileStatus::AwaitingFlip; job.phase = JobPhase::AwaitingFlip; None } // ── Пользователь переложил бумагу → запускаем второй проход ── 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, }) } _ => 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, AxumPath(job_id): AxumPath, ) -> Result, (StatusCode, String)> { let cups_ids: Vec = { let mut m = store.lock().unwrap(); let job = m.get_mut(&job_id) .ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into()))?; if job.phase == JobPhase::Finished || job.phase == JobPhase::Cancelled { return Ok(Json(job.view())); } let ids = job.cups_id.take().into_iter().collect::>(); for f in job.files.iter_mut() { if matches!(f.status, FileStatus::Queued | FileStatus::Printing) { f.status = FileStatus::Cancelled; } } job.phase = JobPhase::Cancelled; job.current = None; ids }; for id in cups_ids { let cancel_bin = cups_bin("cancel"); let _ = tokio::task::spawn_blocking(move || { Command::new(cancel_bin).arg(id).env("LC_ALL", "C").output() }).await; } 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; job.duplex_first_pass_done = false; SpawnInfo { job_id: job.id.clone(), idx, path: job.files[idx].path.to_path_buf(), settings: job.files[idx].settings.clone(), is_second_pass: false, } } fn begin_next_locked(job: &mut Job, from: usize) -> Option { match (from + 1..job.files.len()).find(|&i| job.files[i].status == FileStatus::Queued) { Some(i) => { job.current = Some(i); Some(start_printing_locked(job, i)) } None => { job.phase = JobPhase::Finished; job.current = None; None } } } fn spawn_print_task(store: JobStore, info: SpawnInfo) { tokio::spawn(async move { let path = info.path.clone(); let settings = info.settings.clone(); let is_duplex = settings.as_ref() .map(|s| s.sides.as_deref() == Some("Двусторонняя")) .unwrap_or(false); let duplex_pass: Option<&str> = if is_duplex { if info.is_second_pass { Some("even") } else { Some("odd") } } else { None }; let p = path.clone(); let s = settings.clone(); let dp = duplex_pass.map(|d| d.to_string()); let spool = tokio::task::spawn_blocking(move || { run_lp(&p, s.as_ref(), dp.as_deref()) }).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, }; 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 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 { // ── Первый проход дуплекса завершён ── // Просим пользователя подтвердить что печать закончена job.files[info.idx].status = FileStatus::AwaitingPickup; job.phase = JobPhase::AwaitingPickup; job.duplex_first_pass_done = true; } else { // ── Обычная печать или второй проход дуплекса ── job.files[info.idx].status = FileStatus::AwaitingPickup; job.phase = JobPhase::AwaitingPickup; job.duplex_first_pass_done = false; } job.cups_id = None; }); } async fn mark_error_and_continue(store: JobStore, info: SpawnInfo, msg: String) { 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 / CUPS ─────────────────────────── fn cups_bin(name: &str) -> std::path::PathBuf { let candidates = [ format!("/pkg/gnu/cups/bin/{}", name), format!("/run/current-system/sw/bin/{}", name), 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(); } } 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>, duplex_pass: Option<&str>) -> Result { 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"); } let fmt = normalize_format(&s.format); if !fmt.is_empty() { cmd.arg("-o").arg(format!("PageSize={}", fmt)); } if s.sides.as_deref() == Some("Двусторонняя") && duplex_pass.is_none() { cmd.arg("-o").arg("sides=two-sided-long-edge"); } if let Some(d) = s.dpi.as_deref() { if !d.is_empty() && d != "Авто" { cmd.arg("-o").arg(format!("printer-resolution={}dpi", d)); } } } // ────────────────────────────────────────────────────────── // СЛОТЫ: // • Первый проход дуплекса (odd) → Upper // • Второй проход дуплекса (even) → Lower (ручная подача) // • Обычная печать (не дуплекс) → Upper // ────────────────────────────────────────────────────────── match duplex_pass { Some("odd") => { // ПЕРВЫЙ проход — обычный лоток cmd.arg("-o").arg("InputSlot=Upper"); cmd.arg("-o").arg("page-set=odd"); } Some("even") => { // ВТОРОЙ проход — ручная подача cmd.arg("-o").arg("InputSlot=Lower"); cmd.arg("-o").arg("page-set=even"); cmd.arg("-o").arg("outputorder=reverse"); cmd.arg("-o").arg("orientation-requested=6"); } _ => { // Обычная односторонняя печать cmd.arg("-o").arg("InputSlot=Upper"); } } cmd.arg(path); 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()) }