diff --git a/src/main.rs b/src/main.rs index 238355e..bf5de1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,12 +22,13 @@ use tracing::info; async fn get_version() -> impl IntoResponse { Json(serde_json::json!({ - "version": "1.0.5", + "version": "1.0.6", "timestamp": chrono::Utc::now().timestamp() })) } // ─────────────────────────────── Модели ─────────────────────────────── + #[derive(Deserialize, Clone, Debug)] struct PrintSettings { filename: String, @@ -48,8 +49,9 @@ enum FileStatus { Queued, Printing, Sent, - //AwaitingClearOutput, + AwaitingClearOutput, AwaitingFlip, + AwaitingPickup, Done, Error, Cancelled, @@ -98,6 +100,8 @@ struct Job { current: Option, phase: JobPhase, cups_id: Option, + /// true если только что завершился ПЕРВЫЙ проход дуплекса + duplex_first_pass_done: bool, } impl Job { @@ -116,6 +120,14 @@ impl Job { } } + 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; } @@ -142,6 +154,7 @@ type JobStore = Arc>>; static JOB_COUNTER: AtomicU64 = AtomicU64::new(1); // ─────────────────────────────── main ─────────────────────────────── + #[tokio::main] async fn main() { tracing_subscriber::fmt() @@ -172,11 +185,13 @@ async fn main() { } // ─────────────────────────── Обработчики ─────────────────────────── + 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(); @@ -202,13 +217,20 @@ async fn create_print( 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)) }; + 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, @@ -231,7 +253,9 @@ async fn create_print( 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)) @@ -255,22 +279,41 @@ async fn job_advance( 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); - if is_duplex_and_needs_clear(job, 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 { - Some(start_printing_locked(job, next_idx)) + // Обычная печать или второй проход дуплекса → файл готов + job.files[c].status = FileStatus::Done; + begin_next_locked(job, c) } } + + // ── Пользователь убрал листы из выходного лотка ── JobPhase::AwaitingClearOutput => { let c = job.current.unwrap_or(0); - Some(start_printing_locked(job, c)) + job.files[c].status = FileStatus::AwaitingFlip; + job.phase = JobPhase::AwaitingFlip; + None } + + // ── Пользователь переложил бумагу → запускаем второй проход ── JobPhase::AwaitingFlip => { let c = job.current.unwrap(); job.files[c].status = FileStatus::Printing; @@ -283,15 +326,10 @@ async fn job_advance( is_second_pass: true, }) } - JobPhase::AwaitingPickup => match job.current.take() { - Some(c) => { - job.files[c].status = FileStatus::Done; - begin_next_locked(job, c) - } - None => { job.phase = JobPhase::Finished; None } - }, + _ => None, }; + (job.view(), si) }; @@ -309,7 +347,7 @@ async fn job_cancel( 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())); } @@ -337,11 +375,13 @@ async fn job_cancel( } // ─────────────────────── Печать: 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, @@ -355,12 +395,7 @@ 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); - if is_duplex_and_needs_clear(job, i) { - job.phase = JobPhase::AwaitingClearOutput; - None - } else { - Some(start_printing_locked(job, i)) - } + Some(start_printing_locked(job, i)) } None => { job.phase = JobPhase::Finished; @@ -370,27 +405,27 @@ fn begin_next_locked(job: &mut Job, from: usize) -> Option { } } -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) { 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 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 }; + } else { + None + }; - let p = path.clone(); - let s = settings.clone(); + 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 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, @@ -408,12 +443,18 @@ fn spawn_print_task(store: JobStore, info: SpawnInfo) { } if is_duplex && !info.is_second_pass { - job.files[info.idx].status = FileStatus::AwaitingFlip; - job.phase = JobPhase::AwaitingFlip; - } else { - job.files[info.idx].status = FileStatus::Sent; + // ── Первый проход дуплекса завершён ── + // Просим пользователя подтвердить что печать закончена + 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; }); } @@ -423,6 +464,7 @@ async fn mark_error_and_continue(store: JobStore, info: SpawnInfo, msg: String) 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; @@ -430,10 +472,12 @@ async fn mark_error_and_continue(store: JobStore, info: SpawnInfo, msg: String) } 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), @@ -459,39 +503,66 @@ fn normalize_format(format: &str) -> 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"); } + 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 !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)); } + if !d.is_empty() && d != "Авто" { + cmd.arg("-o").arg(format!("printer-resolution={}dpi", d)); + } } } - // СЛОТЫ: Upper для обычной, Lower для ручной (дуплекс) - if let Some(pass) = duplex_pass { - cmd.arg("-o").arg("InputSlot=Lower"); - if pass == "even" { cmd.arg("-o").arg("orientation-requested=6"); } - } else { - cmd.arg("-o").arg("InputSlot=Upper"); - } - + // ────────────────────────────────────────────────────────── + // СЛОТЫ: + // • Первый проход дуплекса (odd) → Upper + // • Второй проход дуплекса (even) → Lower (ручная подача) + // • Обычная печать (не дуплекс) → Upper + // ────────────────────────────────────────────────────────── match duplex_pass { - Some("odd") => { cmd.arg("-o").arg("page-set=odd"); } + 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()) + + 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()) }