diff --git a/Cargo.toml b/Cargo.toml index 74d3b7c..51c24f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,7 @@ serde_json = "1" bytes = "1" tracing = "0.1" chrono = { version = "0.4", features = ["serde"] } + +[features] +default = [] +local = [] diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..4e7a814 --- /dev/null +++ b/build.rs @@ -0,0 +1,17 @@ +fn main() { + println!("cargo:rerun-if-env-changed=LOCAL"); + println!("cargo:rustc-check-cfg=cfg(local)"); + + let local_env = std::env::var("LOCAL") + .map(|v| { + let v = v.trim().to_ascii_lowercase(); + matches!(v.as_str(), "true" | "1" | "yes" | "on") + }) + .unwrap_or(false); + + let local_feature = std::env::var("CARGO_FEATURE_LOCAL").is_ok(); + + if local_env || local_feature { + println!("cargo:rustc-cfg=local"); + } +} diff --git a/src/general/is_local.rs b/src/general/is_local.rs new file mode 100644 index 0000000..c91ed4a --- /dev/null +++ b/src/general/is_local.rs @@ -0,0 +1,7 @@ +use axum::{response::IntoResponse, Json}; + +pub async fn is_local() -> impl IntoResponse { + // Считываем переменную окружения LOCAL на этапе компиляции + let is_local_env = option_env!("LOCAL").unwrap_or("false") == "true"; + Json(serde_json::json!({ "is_local": is_local_env })) +} diff --git a/src/general/is_server.rs b/src/general/is_server.rs new file mode 100644 index 0000000..18f2b53 --- /dev/null +++ b/src/general/is_server.rs @@ -0,0 +1,6 @@ +use axum::{response::IntoResponse, Json}; + +pub async fn is_server() -> impl IntoResponse { + let is_local_env = option_env!("LOCAL").unwrap_or("false") == "true"; + Json(serde_json::json!({ "is_server": !is_local_env })) +} diff --git a/src/general/mod.rs b/src/general/mod.rs new file mode 100644 index 0000000..8e1c4be --- /dev/null +++ b/src/general/mod.rs @@ -0,0 +1,3 @@ +pub mod version; +pub mod is_local; +pub mod is_server; diff --git a/src/general/version.rs b/src/general/version.rs new file mode 100644 index 0000000..c0abfac --- /dev/null +++ b/src/general/version.rs @@ -0,0 +1,8 @@ +use axum::{response::IntoResponse, Json}; + +pub async fn get_version() -> impl IntoResponse { + Json(serde_json::json!({ + "version": "1.0.7", + "timestamp": chrono::Utc::now().timestamp() + })) +} diff --git a/src/local/mod.rs b/src/local/mod.rs new file mode 100644 index 0000000..6f0a577 --- /dev/null +++ b/src/local/mod.rs @@ -0,0 +1,33 @@ +pub mod print; + +use axum::{ + routing::{get, post}, + Router, +}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use self::{ + print::models::JobStore, + print::{ + advance::job_advance, + cancel::job_cancel, + create::create_print, + status::job_status, + }, +}; + +pub fn routes() -> Router { + let store: JobStore = Arc::new(Mutex::new(HashMap::new())); + + let router: Router = Router::new(); + + router + .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)) + .with_state(store) +} diff --git a/src/local/print/advance.rs b/src/local/print/advance.rs new file mode 100644 index 0000000..105790d --- /dev/null +++ b/src/local/print/advance.rs @@ -0,0 +1,53 @@ +use super::models::*; +use super::printer::{spawn_print_task, start_printing_locked, begin_next_locked}; +use axum::{extract::{Path as AxumPath, State}, http::StatusCode, Json}; + +pub 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)) +} diff --git a/src/local/print/cancel.rs b/src/local/print/cancel.rs new file mode 100644 index 0000000..482ee0b --- /dev/null +++ b/src/local/print/cancel.rs @@ -0,0 +1,34 @@ +use super::models::*; +use super::printer::cups_bin; +use axum::{extract::{Path as AxumPath, State}, http::StatusCode, Json}; +use std::process::Command; + +pub 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())) +} diff --git a/src/local/print/create.rs b/src/local/print/create.rs new file mode 100644 index 0000000..1c1383e --- /dev/null +++ b/src/local/print/create.rs @@ -0,0 +1,60 @@ +use super::models::*; +use axum::{extract::{Multipart, State}, http::StatusCode, Json}; +use std::{path::Path, sync::atomic::Ordering}; +use tempfile::NamedTempFile; +use tokio::fs; +use tracing::info; + +pub async fn create_print( + State(store): State, + mut multipart: Multipart, +) -> Result, (StatusCode, String)> { + info!("📥 Получен запрос на печать"); + let mut settings_list: Vec> = Vec::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" { + let parsed = match field.text().await { + Ok(text) => serde_json::from_str::(&text).ok(), + Err(_) => None, + }; + settings_list.push(parsed); + } 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 (i, (file_name, data)) in files_data.into_iter().enumerate() { + 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_list.get(i).cloned().flatten(); + 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)) +} diff --git a/src/local/print/mod.rs b/src/local/print/mod.rs new file mode 100644 index 0000000..2bd9f81 --- /dev/null +++ b/src/local/print/mod.rs @@ -0,0 +1,6 @@ +pub mod create; +pub mod status; +pub mod advance; +pub mod cancel; +pub mod models; +pub mod printer; diff --git a/src/local/print/models.rs b/src/local/print/models.rs new file mode 100644 index 0000000..85ffbeb --- /dev/null +++ b/src/local/print/models.rs @@ -0,0 +1,112 @@ +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + path::PathBuf, + sync::{ + atomic::AtomicU64, + Arc, Mutex, + }, +}; +use tempfile::TempPath; + +#[derive(Deserialize, Clone, Debug)] +pub struct PrintSettings { + pub pages: String, + pub copies: u32, + #[serde(rename = "colorMode")] + pub color_mode: String, + pub format: String, + #[serde(default)] + pub sides: Option, + #[serde(default)] + pub dpi: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum FileStatus { + Queued, Printing, AwaitingClearOutput, + AwaitingFlip, AwaitingPickup, Done, Error, Cancelled, +} + +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum JobPhase { + WaitingStart, Printing, AwaitingClearOutput, + AwaitingFlip, AwaitingPickup, Finished, Cancelled, +} + +#[derive(Clone, Debug, Serialize)] +pub struct JobFileView { + pub name: String, + pub status: FileStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default)] + pub remove_top_sheet: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct JobView { + pub job_id: String, + pub phase: JobPhase, + pub files: Vec, +} + +pub struct JobFile { + pub name: String, + pub path: TempPath, + pub settings: Option, + pub status: FileStatus, + pub error: Option, +} + +pub struct Job { + pub id: String, + pub files: Vec, + pub current: Option, + pub phase: JobPhase, + pub cups_id: Option, + pub duplex_first_pass_done: bool, +} + +impl Job { + pub 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(), + } + } + + pub 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)] +pub struct SpawnInfo { + pub job_id: String, + pub idx: usize, + pub path: PathBuf, + pub settings: Option, + pub is_second_pass: bool, +} + +pub type JobStore = Arc>>; +pub static JOB_COUNTER: AtomicU64 = AtomicU64::new(1); diff --git a/src/local/print/printer.rs b/src/local/print/printer.rs new file mode 100644 index 0000000..ff3786f --- /dev/null +++ b/src/local/print/printer.rs @@ -0,0 +1,157 @@ +use super::models::*; +use std::{path::Path, process::Command}; + +pub 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, + } +} + +pub 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 + } + } +} + +pub fn spawn_print_task(store: JobStore, info: SpawnInfo) { + tokio::spawn(async move { + let is_duplex = info.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 = info.path.clone(); + let s = info.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); } + + job.files[info.idx].status = FileStatus::AwaitingPickup; + job.phase = JobPhase::AwaitingPickup; + + if is_duplex && !info.is_second_pass { + job.duplex_first_pass_done = true; + } else { + job.duplex_first_pass_done = false; + } + job.cups_id = None; + }); +} + +pub 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); } +} + +pub 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)); + } + } + } + + 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()) +} diff --git a/src/local/print/status.rs b/src/local/print/status.rs new file mode 100644 index 0000000..0906da3 --- /dev/null +++ b/src/local/print/status.rs @@ -0,0 +1,12 @@ +use super::models::*; +use axum::{extract::{Path as AxumPath, State}, http::StatusCode, Json}; + +pub 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())) +} diff --git a/src/main.rs b/src/main.rs index 7a04954..9fcae7a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,160 +1,16 @@ +mod general; + +#[cfg(local)] +mod local; + use axum::{ - extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State}, - http::StatusCode, - response::IntoResponse, - routing::{get, post}, - Json, Router, + extract::DefaultBodyLimit, + routing::get, + 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.7", - "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() @@ -164,404 +20,25 @@ 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("/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)) + .route("/version", get(general::version::get_version)) + .route("/is_local", get(general::is_local::is_local)) + .route("/is_server", get(general::is_server::is_server)); + + #[cfg(local)] + let app = app.merge(local::routes()); + + let app = app .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) - .layer(cors) - .with_state(store); + .layer(CorsLayer::permissive()); 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_list: Vec> = Vec::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" { - let parsed = match field.text().await { - Ok(text) => serde_json::from_str::(&text).ok(), - Err(_) => None, - }; - // Пушим даже при ошибке парсинга (None), чтобы индексы не съезжали - settings_list.push(parsed); - } 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 (i, (file_name, data)) in files_data.into_iter().enumerate() { - 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() { - // i-й файл ↔ i-й набор настроек — имена могут совпадать - let settings = settings_list.get(i).cloned().flatten(); - - 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)); - } - } - } - - 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()) + + axum::serve(listener, app) + .await + .expect("Ошибка сервера"); }