code rewritted to modules; add local/server flags to build
This commit is contained in:
@ -14,3 +14,7 @@ serde_json = "1"
|
|||||||
bytes = "1"
|
bytes = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
local = []
|
||||||
|
|||||||
17
build.rs
Normal file
17
build.rs
Normal file
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/general/is_local.rs
Normal file
7
src/general/is_local.rs
Normal file
@ -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 }))
|
||||||
|
}
|
||||||
6
src/general/is_server.rs
Normal file
6
src/general/is_server.rs
Normal file
@ -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 }))
|
||||||
|
}
|
||||||
3
src/general/mod.rs
Normal file
3
src/general/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
pub mod version;
|
||||||
|
pub mod is_local;
|
||||||
|
pub mod is_server;
|
||||||
8
src/general/version.rs
Normal file
8
src/general/version.rs
Normal file
@ -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()
|
||||||
|
}))
|
||||||
|
}
|
||||||
33
src/local/mod.rs
Normal file
33
src/local/mod.rs
Normal file
@ -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<JobStore> = 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)
|
||||||
|
}
|
||||||
53
src/local/print/advance.rs
Normal file
53
src/local/print/advance.rs
Normal file
@ -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<JobStore>,
|
||||||
|
AxumPath(job_id): AxumPath<String>,
|
||||||
|
) -> Result<Json<JobView>, (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))
|
||||||
|
}
|
||||||
34
src/local/print/cancel.rs
Normal file
34
src/local/print/cancel.rs
Normal file
@ -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<JobStore>,
|
||||||
|
AxumPath(job_id): AxumPath<String>,
|
||||||
|
) -> Result<Json<JobView>, (StatusCode, String)> {
|
||||||
|
let cups_ids: Vec<String> = {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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()))
|
||||||
|
}
|
||||||
60
src/local/print/create.rs
Normal file
60
src/local/print/create.rs
Normal file
@ -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<JobStore>,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Json<JobView>, (StatusCode, String)> {
|
||||||
|
info!("📥 Получен запрос на печать");
|
||||||
|
let mut settings_list: Vec<Option<PrintSettings>> = 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::<PrintSettings>(&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<JobFile> = 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))
|
||||||
|
}
|
||||||
6
src/local/print/mod.rs
Normal file
6
src/local/print/mod.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
pub mod create;
|
||||||
|
pub mod status;
|
||||||
|
pub mod advance;
|
||||||
|
pub mod cancel;
|
||||||
|
pub mod models;
|
||||||
|
pub mod printer;
|
||||||
112
src/local/print/models.rs
Normal file
112
src/local/print/models.rs
Normal file
@ -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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dpi: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub remove_top_sheet: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct JobView {
|
||||||
|
pub job_id: String,
|
||||||
|
pub phase: JobPhase,
|
||||||
|
pub files: Vec<JobFileView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JobFile {
|
||||||
|
pub name: String,
|
||||||
|
pub path: TempPath,
|
||||||
|
pub settings: Option<PrintSettings>,
|
||||||
|
pub status: FileStatus,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Job {
|
||||||
|
pub id: String,
|
||||||
|
pub files: Vec<JobFile>,
|
||||||
|
pub current: Option<usize>,
|
||||||
|
pub phase: JobPhase,
|
||||||
|
pub cups_id: Option<String>,
|
||||||
|
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<PrintSettings>) -> 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<u32> = 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<PrintSettings>,
|
||||||
|
pub is_second_pass: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type JobStore = Arc<Mutex<HashMap<String, Job>>>;
|
||||||
|
pub static JOB_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||||
157
src/local/print/printer.rs
Normal file
157
src/local/print/printer.rs
Normal file
@ -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<SpawnInfo> {
|
||||||
|
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<String, String> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
12
src/local/print/status.rs
Normal file
12
src/local/print/status.rs
Normal file
@ -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<JobStore>,
|
||||||
|
AxumPath(job_id): AxumPath<String>,
|
||||||
|
) -> Result<Json<JobView>, (StatusCode, String)> {
|
||||||
|
let m = store.lock().unwrap();
|
||||||
|
m.get(&job_id)
|
||||||
|
.map(|j| Json(j.view()))
|
||||||
|
.ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into()))
|
||||||
|
}
|
||||||
567
src/main.rs
567
src/main.rs
@ -1,160 +1,16 @@
|
|||||||
|
mod general;
|
||||||
|
|
||||||
|
#[cfg(local)]
|
||||||
|
mod local;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State},
|
extract::DefaultBodyLimit,
|
||||||
http::StatusCode,
|
routing::get,
|
||||||
response::IntoResponse,
|
Router,
|
||||||
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 tower_http::cors::CorsLayer;
|
||||||
use tracing::info;
|
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<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
dpi: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
remove_top_sheet: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
struct JobView {
|
|
||||||
job_id: String,
|
|
||||||
phase: JobPhase,
|
|
||||||
files: Vec<JobFileView>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct JobFile {
|
|
||||||
name: String,
|
|
||||||
path: TempPath,
|
|
||||||
settings: Option<PrintSettings>,
|
|
||||||
status: FileStatus,
|
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Job {
|
|
||||||
id: String,
|
|
||||||
files: Vec<JobFile>,
|
|
||||||
current: Option<usize>,
|
|
||||||
phase: JobPhase,
|
|
||||||
cups_id: Option<String>,
|
|
||||||
/// 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<PrintSettings>) -> 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<u32> = 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<PrintSettings>,
|
|
||||||
is_second_pass: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
type JobStore = Arc<Mutex<HashMap<String, Job>>>;
|
|
||||||
static JOB_COUNTER: AtomicU64 = AtomicU64::new(1);
|
|
||||||
|
|
||||||
// ─────────────────────────────── main ───────────────────────────────
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
@ -164,404 +20,25 @@ async fn main() {
|
|||||||
.with_line_number(true)
|
.with_line_number(true)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let store: JobStore = Arc::new(Mutex::new(HashMap::new()));
|
|
||||||
let cors = CorsLayer::permissive();
|
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/version", get(get_version))
|
.route("/version", get(general::version::get_version))
|
||||||
.route("/print", post(create_print))
|
.route("/is_local", get(general::is_local::is_local))
|
||||||
.route("/print/{job_id}", get(job_status))
|
.route("/is_server", get(general::is_server::is_server));
|
||||||
.route("/print/{job_id}/advance", post(job_advance))
|
|
||||||
.route("/print/{job_id}/cancel", post(job_cancel))
|
#[cfg(local)]
|
||||||
|
let app = app.merge(local::routes());
|
||||||
|
|
||||||
|
let app = app
|
||||||
.layer(DefaultBodyLimit::max(50 * 1024 * 1024))
|
.layer(DefaultBodyLimit::max(50 * 1024 * 1024))
|
||||||
.layer(cors)
|
.layer(CorsLayer::permissive());
|
||||||
.with_state(store);
|
|
||||||
|
|
||||||
info!("🖨️ Print API запущен на http://0.0.0.0:3000");
|
info!("🖨️ Print API запущен на http://0.0.0.0:3000");
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
|
||||||
.await
|
.await
|
||||||
.expect("Не удалось забиндить порт 3000");
|
.expect("Не удалось забиндить порт 3000");
|
||||||
axum::serve(listener, app).await.expect("Ошибка сервера");
|
|
||||||
}
|
axum::serve(listener, app)
|
||||||
|
.await
|
||||||
// ─────────────────────────── Обработчики ───────────────────────────
|
.expect("Ошибка сервера");
|
||||||
|
|
||||||
async fn create_print(
|
|
||||||
State(store): State<JobStore>,
|
|
||||||
mut multipart: Multipart,
|
|
||||||
) -> Result<Json<JobView>, (StatusCode, String)> {
|
|
||||||
info!("📥 Получен запрос на печать");
|
|
||||||
|
|
||||||
let mut settings_list: Vec<Option<PrintSettings>> = 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::<PrintSettings>(&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<JobFile> = 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<JobStore>,
|
|
||||||
AxumPath(job_id): AxumPath<String>,
|
|
||||||
) -> Result<Json<JobView>, (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<JobStore>,
|
|
||||||
AxumPath(job_id): AxumPath<String>,
|
|
||||||
) -> Result<Json<JobView>, (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<JobStore>,
|
|
||||||
AxumPath(job_id): AxumPath<String>,
|
|
||||||
) -> Result<Json<JobView>, (StatusCode, String)> {
|
|
||||||
let cups_ids: Vec<String> = {
|
|
||||||
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::<Vec<_>>();
|
|
||||||
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<SpawnInfo> {
|
|
||||||
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<String, String> {
|
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user