This commit is contained in:
2026-09-18 21:30:19 +03:00
parent 05dd345205
commit 6312d813a7

View File

@ -22,12 +22,13 @@ 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.5", "version": "1.0.6",
"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,
@ -48,8 +49,9 @@ enum FileStatus {
Queued, Queued,
Printing, Printing,
Sent, Sent,
//AwaitingClearOutput, AwaitingClearOutput,
AwaitingFlip, AwaitingFlip,
AwaitingPickup,
Done, Done,
Error, Error,
Cancelled, Cancelled,
@ -98,6 +100,8 @@ struct Job {
current: Option<usize>, current: Option<usize>,
phase: JobPhase, phase: JobPhase,
cups_id: Option<String>, cups_id: Option<String>,
/// true если только что завершился ПЕРВЫЙ проход дуплекса
duplex_first_pass_done: bool,
} }
impl Job { 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<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; }
@ -142,6 +154,7 @@ 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()
@ -172,11 +185,13 @@ 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();
@ -202,13 +217,20 @@ async fn create_print(
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).extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); let ext = Path::new(&file_name)
let temp = if ext.is_empty() { NamedTempFile::new() } else { NamedTempFile::with_suffix(&format!(".{}", ext)) }; .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 { if let Ok(temp) = temp {
let path = temp.into_temp_path(); let path = temp.into_temp_path();
if fs::write(&*path, &data).await.is_ok() { if fs::write(&*path, &data).await.is_ok() {
let settings = settings_map.get(&file_name).cloned(); let settings = settings_map.get(&file_name).cloned();
job_files.push(JobFile { job_files.push(JobFile {
name: file_name, name: file_name,
path, path,
@ -231,7 +253,9 @@ async fn create_print(
current: None, current: None,
phase: JobPhase::WaitingStart, phase: JobPhase::WaitingStart,
cups_id: None, cups_id: None,
duplex_first_pass_done: false,
}; };
let view = job.view(); let view = job.view();
store.lock().unwrap().insert(job_id.clone(), job); store.lock().unwrap().insert(job_id.clone(), job);
Ok(Json(view)) Ok(Json(view))
@ -257,20 +281,39 @@ async fn job_advance(
.ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into()))?; .ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into()))?;
let si = match job.phase { let si = match job.phase {
// ── Старт: первый файл ──
JobPhase::WaitingStart => { JobPhase::WaitingStart => {
let next_idx = 0; let next_idx = 0;
job.current = Some(next_idx); 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; job.phase = JobPhase::AwaitingClearOutput;
None None
} else { } else {
Some(start_printing_locked(job, next_idx)) // Обычная печать или второй проход дуплекса → файл готов
job.files[c].status = FileStatus::Done;
begin_next_locked(job, c)
} }
} }
// ── Пользователь убрал листы из выходного лотка ──
JobPhase::AwaitingClearOutput => { JobPhase::AwaitingClearOutput => {
let c = job.current.unwrap_or(0); 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 => { JobPhase::AwaitingFlip => {
let c = job.current.unwrap(); let c = job.current.unwrap();
job.files[c].status = FileStatus::Printing; job.files[c].status = FileStatus::Printing;
@ -283,15 +326,10 @@ async fn job_advance(
is_second_pass: true, 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, _ => None,
}; };
(job.view(), si) (job.view(), si)
}; };
@ -337,11 +375,13 @@ async fn job_cancel(
} }
// ─────────────────────── Печать: 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);
job.phase = JobPhase::Printing; job.phase = JobPhase::Printing;
job.cups_id = None; job.cups_id = None;
job.duplex_first_pass_done = false;
SpawnInfo { SpawnInfo {
job_id: job.id.clone(), job_id: job.id.clone(),
idx, idx,
@ -355,13 +395,8 @@ 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(i) => {
job.current = 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 => { None => {
job.phase = JobPhase::Finished; job.phase = JobPhase::Finished;
job.current = None; job.current = None;
@ -370,27 +405,27 @@ fn begin_next_locked(job: &mut Job, from: usize) -> Option<SpawnInfo> {
} }
} }
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 { None }; } else {
None
};
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 || 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 { let cups_id = match spool {
Ok(Ok(id)) => id, Ok(Ok(id)) => id,
@ -408,12 +443,18 @@ fn spawn_print_task(store: JobStore, info: SpawnInfo) {
} }
if is_duplex && !info.is_second_pass { 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::AwaitingPickup;
job.files[info.idx].status = FileStatus::Sent;
job.phase = JobPhase::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; 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 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 let Some(f) = job.files.get_mut(info.idx) { if let Some(f) = job.files.get_mut(info.idx) {
if f.status != FileStatus::Printing { return; } if f.status != FileStatus::Printing { return; }
f.status = FileStatus::Error; 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) begin_next_locked(job, info.idx)
}; };
if let Some(si) = next { spawn_print_task(store, si); } if let Some(si) = next { spawn_print_task(store, si); }
} }
// ─────────────────────────── lp / CUPS ─────────────────────────── // ─────────────────────────── lp / CUPS ───────────────────────────
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), 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<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() { 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() { 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 != "Авто" { 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"); // • Первый проход дуплекса (odd) → Upper
if pass == "even" { cmd.arg("-o").arg("orientation-requested=6"); } // • Второй проход дуплекса (even) → Lower (ручная подача)
} else { // • Обычная печать (не дуплекс) → Upper
cmd.arg("-o").arg("InputSlot=Upper"); // ──────────────────────────────────────────────────────────
}
match duplex_pass { 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") => { Some("even") => {
// ВТОРОЙ проход — ручная подача
cmd.arg("-o").arg("InputSlot=Lower");
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");
cmd.arg("-o").arg("orientation-requested=6");
}
_ => {
// Обычная односторонняя печать
cmd.arg("-o").arg("InputSlot=Upper");
} }
_ => {}
} }
cmd.arg(path); 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()); } let out = cmd.env("LC_ALL", "C").output()
Ok(String::from_utf8_lossy(&out.stdout).split_whitespace().nth(3).unwrap_or("").to_string()) .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())
} }