feat(linux): capture original CUPS PDF before driver rendering
Some checks failed
Desktop Release / Bootstrap updater signing key (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB + AppImage (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled
Some checks failed
Desktop Release / Bootstrap updater signing key (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB + AppImage (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled
This commit is contained in:
parent
496192a12b
commit
05284b6d64
@ -56,6 +56,6 @@ FieldLens Print can create an OS printer queue that appears in normal applicatio
|
|||||||
|
|
||||||
Windows creates the virtual queue with the selected destination printer's existing driver and points it at `127.0.0.1:9100`, so the captured stream is already in the destination printer's language before FieldLens inspects and forwards it.
|
Windows creates the virtual queue with the selected destination printer's existing driver and points it at `127.0.0.1:9100`, so the captured stream is already in the destination printer's language before FieldLens inspects and forwards it.
|
||||||
|
|
||||||
Linux creates a CUPS raw queue named `FieldLens-Print` with display name **FieldLens Print** and routes it to the same local RAW listener.
|
Linux creates a CUPS queue named `FieldLens-Print` using the selected physical printer's capabilities and device URI. A FieldLens CUPS PDF pre-filter copies the original submitted PDF into `/var/spool/fieldlens-print` before the physical printer driver renders it, then passes the PDF onward unchanged through the normal CUPS filter chain. FieldLens Print watches that spool for capture/decoding while CUPS continues printing normally.
|
||||||
|
|
||||||
USB/shared queues may not expose a RAW/TCP address. In that case the installed-printer selector still supplies the driver for the virtual Windows queue, while the physical destination address must be entered manually.
|
USB/shared queues may not expose a RAW/TCP address. In that case the installed-printer selector still supplies the driver for the virtual Windows queue, while the physical destination address must be entered manually.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@fieldlens/print",
|
"name": "@fieldlens/print",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "fieldlens-print"
|
name = "fieldlens-print"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
description = "FieldLens print-stream barcode capture companion"
|
description = "FieldLens print-stream barcode capture companion"
|
||||||
authors = ["Arrowstack"]
|
authors = ["Arrowstack"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@ -24,6 +24,9 @@ use crate::{
|
|||||||
zpl::extract_barcodes,
|
zpl::extract_barcodes,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
const SYSTEM_CUPS_SPOOL_DIR: &str = "/var/spool/fieldlens-print";
|
||||||
|
|
||||||
pub struct CaptureRuntime {
|
pub struct CaptureRuntime {
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
handles: Vec<JoinHandle<()>>,
|
handles: Vec<JoinHandle<()>>,
|
||||||
@ -66,10 +69,40 @@ pub fn start(config: AppConfig, state: SharedState, app: AppHandle) -> Result<Ca
|
|||||||
let worker_app = app.clone();
|
let worker_app = app.clone();
|
||||||
let worker_config = config.clone();
|
let worker_config = config.clone();
|
||||||
handles.push(thread::spawn(move || {
|
handles.push(thread::spawn(move || {
|
||||||
spool_folder_loop(worker_config, worker_state, worker_app, worker_stop)
|
spool_folder_loop(
|
||||||
|
worker_config,
|
||||||
|
worker_state,
|
||||||
|
worker_app,
|
||||||
|
worker_stop,
|
||||||
|
None,
|
||||||
|
"spool-folder",
|
||||||
|
false,
|
||||||
|
)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let system_spool = PathBuf::from(SYSTEM_CUPS_SPOOL_DIR);
|
||||||
|
if system_spool.is_dir() && system_spool != PathBuf::from(&config.spool_dir) {
|
||||||
|
let worker_stop = stop.clone();
|
||||||
|
let worker_state = state.clone();
|
||||||
|
let worker_app = app.clone();
|
||||||
|
let worker_config = config.clone();
|
||||||
|
handles.push(thread::spawn(move || {
|
||||||
|
spool_folder_loop(
|
||||||
|
worker_config,
|
||||||
|
worker_state,
|
||||||
|
worker_app,
|
||||||
|
worker_stop,
|
||||||
|
Some(system_spool),
|
||||||
|
"cups-original",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if handles.is_empty() {
|
if handles.is_empty() {
|
||||||
return Err(anyhow!("no capture route enabled"));
|
return Err(anyhow!("no capture route enabled"));
|
||||||
}
|
}
|
||||||
@ -200,8 +233,11 @@ fn spool_folder_loop(
|
|||||||
state: SharedState,
|
state: SharedState,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
|
spool_override: Option<PathBuf>,
|
||||||
|
source: &'static str,
|
||||||
|
already_forwarded: bool,
|
||||||
) {
|
) {
|
||||||
let spool_dir = PathBuf::from(&config.spool_dir);
|
let spool_dir = spool_override.unwrap_or_else(|| PathBuf::from(&config.spool_dir));
|
||||||
let processed_dir = spool_dir.join("processed");
|
let processed_dir = spool_dir.join("processed");
|
||||||
let _ = fs::create_dir_all(&processed_dir);
|
let _ = fs::create_dir_all(&processed_dir);
|
||||||
while !stop.load(Ordering::SeqCst) {
|
while !stop.load(Ordering::SeqCst) {
|
||||||
@ -251,11 +287,15 @@ fn spool_folder_loop(
|
|||||||
|
|
||||||
match fs::read(&path) {
|
match fs::read(&path) {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
let (forwarded, error) = forward_payload(&config, &bytes);
|
let (forwarded, error) = if already_forwarded {
|
||||||
|
(true, None)
|
||||||
|
} else {
|
||||||
|
forward_payload(&config, &bytes)
|
||||||
|
};
|
||||||
process_payload(
|
process_payload(
|
||||||
&state,
|
&state,
|
||||||
&app,
|
&app,
|
||||||
"spool-folder",
|
source,
|
||||||
label.clone(),
|
label.clone(),
|
||||||
bytes,
|
bytes,
|
||||||
forwarded,
|
forwarded,
|
||||||
@ -268,7 +308,7 @@ fn spool_folder_loop(
|
|||||||
Err(error) => process_payload(
|
Err(error) => process_payload(
|
||||||
&state,
|
&state,
|
||||||
&app,
|
&app,
|
||||||
"spool-folder",
|
source,
|
||||||
label,
|
label,
|
||||||
vec![],
|
vec![],
|
||||||
false,
|
false,
|
||||||
@ -287,7 +327,7 @@ fn is_supported_drop(path: &std::path::Path) -> bool {
|
|||||||
.and_then(|value| value.to_str())
|
.and_then(|value| value.to_str())
|
||||||
.map(|value| value.to_ascii_lowercase())
|
.map(|value| value.to_ascii_lowercase())
|
||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some("zpl" | "raw" | "prn" | "spl")
|
Some("zpl" | "raw" | "prn" | "spl" | "pdf" | "ps" | "png" | "jpg" | "jpeg")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||||
use std::fs;
|
use std::fs;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -13,6 +13,82 @@ use serde_json::Value;
|
|||||||
pub const VIRTUAL_PRINTER_NAME: &str = "FieldLens Print";
|
pub const VIRTUAL_PRINTER_NAME: &str = "FieldLens Print";
|
||||||
const WINDOWS_PORT_NAME: &str = "FieldLensPrint-9100";
|
const WINDOWS_PORT_NAME: &str = "FieldLensPrint-9100";
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
const FIELDLENS_CUPS_SPOOL_DIR: &str = "/var/spool/fieldlens-print";
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
const FIELDLENS_CUPS_FILTER: &str = r#"#!/usr/bin/python3
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
SPOOL = "/var/spool/fieldlens-print"
|
||||||
|
|
||||||
|
def safe(value):
|
||||||
|
value = re.sub(r"[^A-Za-z0-9._-]+", "-", value or "job").strip("-")
|
||||||
|
return value[:80] or "job"
|
||||||
|
|
||||||
|
job_id = safe(sys.argv[1] if len(sys.argv) > 1 else "job")
|
||||||
|
title = safe(sys.argv[3] if len(sys.argv) > 3 else "print")
|
||||||
|
content_type = os.environ.get("CONTENT_TYPE", "application/pdf")
|
||||||
|
ext = {
|
||||||
|
"application/pdf": "pdf",
|
||||||
|
"application/postscript": "ps",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
}.get(content_type, "dat")
|
||||||
|
name = f"{job_id}-{title}-{uuid.uuid4().hex}.{ext}"
|
||||||
|
final_path = os.path.join(SPOOL, name)
|
||||||
|
part_path = final_path + ".part"
|
||||||
|
|
||||||
|
source = None
|
||||||
|
capture = None
|
||||||
|
captured_ok = False
|
||||||
|
try:
|
||||||
|
if len(sys.argv) > 6 and sys.argv[6]:
|
||||||
|
source = open(sys.argv[6], "rb")
|
||||||
|
else:
|
||||||
|
source = sys.stdin.buffer
|
||||||
|
try:
|
||||||
|
capture = open(part_path, "wb")
|
||||||
|
except OSError:
|
||||||
|
capture = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
chunk = source.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
if capture is not None:
|
||||||
|
try:
|
||||||
|
capture.write(chunk)
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
capture.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
capture = None
|
||||||
|
sys.stdout.buffer.write(chunk)
|
||||||
|
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
if capture is not None:
|
||||||
|
capture.flush()
|
||||||
|
capture.close()
|
||||||
|
capture = None
|
||||||
|
os.chmod(part_path, 0o644)
|
||||||
|
os.replace(part_path, final_path)
|
||||||
|
captured_ok = True
|
||||||
|
finally:
|
||||||
|
if source is not None and source is not sys.stdin.buffer:
|
||||||
|
source.close()
|
||||||
|
if capture is not None:
|
||||||
|
capture.close()
|
||||||
|
if not captured_ok:
|
||||||
|
try:
|
||||||
|
os.unlink(part_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
"#;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SystemPrinter {
|
pub struct SystemPrinter {
|
||||||
@ -344,7 +420,7 @@ fn linux_virtual_printer_status() -> Result<VirtualPrinterStatus> {
|
|||||||
installed: ready,
|
installed: ready,
|
||||||
name: VIRTUAL_PRINTER_NAME.into(),
|
name: VIRTUAL_PRINTER_NAME.into(),
|
||||||
detail: if ready {
|
detail: if ready {
|
||||||
"Ready and accepting jobs in the system print dialog.".into()
|
"Ready. Original PDF jobs are captured before the physical printer driver renders them.".into()
|
||||||
} else {
|
} else {
|
||||||
"FieldLens Print exists but CUPS has it disabled or not accepting jobs. Click Create / repair FieldLens Print.".into()
|
"FieldLens Print exists but CUPS has it disabled or not accepting jobs. Click Create / repair FieldLens Print.".into()
|
||||||
},
|
},
|
||||||
@ -370,48 +446,131 @@ fn run_cups_repair_command(program: &str, args: &[&str]) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn install_linux_virtual_printer(target_printer: &str) -> Result<()> {
|
fn cups_filter_dir() -> Result<&'static Path> {
|
||||||
let printers = list_linux_printers()?;
|
for candidate in ["/usr/lib/cups/filter", "/usr/libexec/cups/filter"] {
|
||||||
if !printers.iter().any(|printer| printer.name == target_printer && !printer.is_virtual) {
|
let path = Path::new(candidate);
|
||||||
|
if path.is_dir() {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(anyhow!("CUPS filter directory was not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn run_linux_admin(program: &str, args: &[String], context: &str) -> Result<()> {
|
||||||
|
let direct = Command::new(program).args(args).status();
|
||||||
|
if matches!(direct, Ok(status) if status.success()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = Command::new("pkexec")
|
||||||
|
.arg(program)
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("run elevated {context}"))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(anyhow!("{context} failed; administrator approval may be required"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn install_linux_capture_filter() -> Result<()> {
|
||||||
|
let filter_dir = cups_filter_dir()?;
|
||||||
|
let filter_path = filter_dir.join("fieldlens-capture");
|
||||||
|
let temp_filter = std::env::temp_dir().join(format!(
|
||||||
|
"fieldlens-capture-{}.py",
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
fs::write(&temp_filter, FIELDLENS_CUPS_FILTER).context("write FieldLens CUPS pre-filter")?;
|
||||||
|
|
||||||
|
let install_filter_args = vec![
|
||||||
|
"-m".to_string(),
|
||||||
|
"0755".to_string(),
|
||||||
|
temp_filter.to_string_lossy().into_owned(),
|
||||||
|
filter_path.to_string_lossy().into_owned(),
|
||||||
|
];
|
||||||
|
let filter_result = run_linux_admin(
|
||||||
|
"install",
|
||||||
|
&install_filter_args,
|
||||||
|
"install FieldLens CUPS pre-filter",
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&temp_filter);
|
||||||
|
filter_result?;
|
||||||
|
|
||||||
|
let spool_args = vec![
|
||||||
|
"-d".to_string(),
|
||||||
|
"-m".to_string(),
|
||||||
|
"0777".to_string(),
|
||||||
|
FIELDLENS_CUPS_SPOOL_DIR.to_string(),
|
||||||
|
];
|
||||||
|
run_linux_admin("install", &spool_args, "create FieldLens CUPS capture spool")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn build_fieldlens_ppd(target_printer: &str) -> Result<PathBuf> {
|
||||||
|
let source = PathBuf::from("/etc/cups/ppd").join(format!("{target_printer}.ppd"));
|
||||||
|
if !source.exists() {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"selected printer '{target_printer}' is not installed or available in CUPS"
|
"CUPS PPD for '{target_printer}' was not found; FieldLens needs the installed printer definition to preserve browser print capabilities"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let ppd_path = PathBuf::from("/etc/cups/ppd").join(format!("{target_printer}.ppd"));
|
let original = fs::read_to_string(&source)
|
||||||
let mut args = vec![
|
.with_context(|| format!("read target printer PPD {}", source.display()))?;
|
||||||
|
let mut cleaned = original
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.contains("fieldlens-capture"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
cleaned.push('\n');
|
||||||
|
|
||||||
|
let prefilter = concat!(
|
||||||
|
"*% FieldLens original-document capture before printer rendering\n",
|
||||||
|
"*cupsPreFilter: \"application/pdf 0 fieldlens-capture\"\n"
|
||||||
|
);
|
||||||
|
if let Some(index) = cleaned.rfind("*% End") {
|
||||||
|
cleaned.insert_str(index, prefilter);
|
||||||
|
} else {
|
||||||
|
cleaned.push_str(prefilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = std::env::temp_dir().join(format!(
|
||||||
|
"fieldlens-{}.ppd",
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
fs::write(&target, cleaned).context("write FieldLens derived PPD")?;
|
||||||
|
Ok(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn install_linux_virtual_printer(target_printer: &str) -> Result<()> {
|
||||||
|
let printers = list_linux_printers()?;
|
||||||
|
let target = printers
|
||||||
|
.iter()
|
||||||
|
.find(|printer| printer.name == target_printer && !printer.is_virtual)
|
||||||
|
.ok_or_else(|| anyhow!(
|
||||||
|
"selected printer '{target_printer}' is not installed or available in CUPS"
|
||||||
|
))?;
|
||||||
|
|
||||||
|
install_linux_capture_filter()?;
|
||||||
|
let ppd_path = build_fieldlens_ppd(target_printer)?;
|
||||||
|
let args = vec![
|
||||||
"-p".to_string(),
|
"-p".to_string(),
|
||||||
"FieldLens-Print".to_string(),
|
"FieldLens-Print".to_string(),
|
||||||
"-E".to_string(),
|
"-E".to_string(),
|
||||||
"-v".to_string(),
|
"-v".to_string(),
|
||||||
"socket://127.0.0.1:9100".to_string(),
|
target.port.clone(),
|
||||||
|
"-P".to_string(),
|
||||||
|
ppd_path.to_string_lossy().into_owned(),
|
||||||
|
"-D".to_string(),
|
||||||
|
VIRTUAL_PRINTER_NAME.to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ppd_path.exists() {
|
let setup_result = run_linux_admin("lpadmin", &args, "configure FieldLens CUPS printer");
|
||||||
args.push("-P".to_string());
|
let _ = fs::remove_file(&ppd_path);
|
||||||
args.push(ppd_path.to_string_lossy().into_owned());
|
setup_result?;
|
||||||
} else {
|
|
||||||
args.push("-m".to_string());
|
|
||||||
args.push("raw".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push("-D".to_string());
|
|
||||||
args.push(VIRTUAL_PRINTER_NAME.to_string());
|
|
||||||
|
|
||||||
let direct = Command::new("lpadmin").args(&args).status();
|
|
||||||
if !matches!(direct, Ok(status) if status.success()) {
|
|
||||||
let status = Command::new("pkexec")
|
|
||||||
.arg("lpadmin")
|
|
||||||
.args(&args)
|
|
||||||
.status()
|
|
||||||
.context("run elevated CUPS printer setup")?;
|
|
||||||
|
|
||||||
if !status.success() {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"CUPS printer setup failed; administrator approval may be required"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lpadmin -E normally enables the queue, but explicitly repair both CUPS
|
// lpadmin -E normally enables the queue, but explicitly repair both CUPS
|
||||||
// state flags because Chromium disables Print when either is stale.
|
// state flags because Chromium disables Print when either is stale.
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "FieldLens Print",
|
"productName": "FieldLens Print",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"identifier": "dev.arrowstack.fieldlens.print",
|
"identifier": "dev.arrowstack.fieldlens.print",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@ -38,7 +38,8 @@
|
|||||||
"linux": {
|
"linux": {
|
||||||
"deb": {
|
"deb": {
|
||||||
"depends": [
|
"depends": [
|
||||||
"cups-client"
|
"cups-client",
|
||||||
|
"python3"
|
||||||
],
|
],
|
||||||
"recommends": [
|
"recommends": [
|
||||||
"policykit-1"
|
"policykit-1"
|
||||||
|
|||||||
@ -85,7 +85,7 @@ let config: AppConfig
|
|||||||
let jobs: Job[] = []
|
let jobs: Job[] = []
|
||||||
let status: Status
|
let status: Status
|
||||||
let loginTimer: number | undefined
|
let loginTimer: number | undefined
|
||||||
let currentVersion = '0.1.6'
|
let currentVersion = '0.1.7'
|
||||||
let bundleType: BundleType | null = null
|
let bundleType: BundleType | null = null
|
||||||
let availableUpdate: Update | null = null
|
let availableUpdate: Update | null = null
|
||||||
let availableManualUpdate: ManualUpdate | null = null
|
let availableManualUpdate: ManualUpdate | null = null
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user