Initial commit: rdgen with GitHub/Gitea dual build backend
This commit is contained in:
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "printer_driver_adapter"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "printer_driver_adapter"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Open drop-in replacement for RustDesk's printer_driver_adapter.dll"
|
||||
|
||||
[lib]
|
||||
name = "printer_driver_adapter"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
strip = true
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,186 @@
|
||||
# Open `printer_driver_adapter.dll`
|
||||
|
||||
A drop-in replacement for RustDesk's closed printer adapter, so remote printing
|
||||
works in custom-branded builds.
|
||||
|
||||
## Why this exists
|
||||
|
||||
RustDesk's `printer_driver_adapter.dll` verifies the **calling executable's
|
||||
Authenticode signature** inside `init()` and refuses to run for anything not
|
||||
signed by RustDesk. Evidence from the shipped binary:
|
||||
|
||||
- imports `WinVerifyTrust` (wintrust.dll), `CertGetNameStringW`, `GetModuleFileNameW`
|
||||
- links `codesign-verify-rs`, a crate whose only purpose is Authenticode checks
|
||||
- carries one hardcoded identity constant: `PURSLANEHUABINGRUSTDESK` followed by
|
||||
two SHA-256 digests. Purslane Ltd is RustDesk's company; Huabing is the founder.
|
||||
|
||||
An unsigned custom build fails with:
|
||||
|
||||
```
|
||||
ERROR [src\server.rs:160] printer service init failed: Failed to init printer driver
|
||||
```
|
||||
|
||||
which is the `fn_init() != 0` branch. Signing with *your own* certificate does
|
||||
not help — the check is an allow-list of specific identities, not "is it signed".
|
||||
|
||||
Everything else in RustDesk's printing chain is open source and works. Only the
|
||||
capture shim is gated, and it is replaceable: this crate reimplements the same
|
||||
four-function ABI with no signature check.
|
||||
|
||||
## The ABI
|
||||
|
||||
From `src/server/printer_service.rs`. Symbols are resolved by these exact
|
||||
undecorated names, so all exports are `#[no_mangle] extern "C"`:
|
||||
|
||||
```rust
|
||||
pub type Init = fn(tag_name: *const i8) -> i32; // 0 = success
|
||||
pub type Uninit = fn();
|
||||
pub type GetPrnData = fn(dur_mills: u32, data: *mut *mut i8, data_len: *mut u32);
|
||||
pub type FreePrnData = fn(data: *mut i8);
|
||||
```
|
||||
|
||||
Verified: this DLL's PE export table is byte-for-byte the same set of names as
|
||||
RustDesk's own (`init`, `uninit`, `get_prn_data`, `free_prn_data`).
|
||||
|
||||
## How capture works
|
||||
|
||||
No custom print driver, therefore no WHQL signing:
|
||||
|
||||
```
|
||||
print job
|
||||
│
|
||||
▼
|
||||
virtual printer ──► Local Port whose NAME IS A FILE PATH
|
||||
(signed driver) %ProgramData%\<Tag>\printer-spool\job.prn
|
||||
│
|
||||
▼
|
||||
this adapter polls the directory,
|
||||
opens each file with share_mode = 0
|
||||
(fails while the spooler still holds it),
|
||||
returns the bytes, deletes the file
|
||||
│
|
||||
▼
|
||||
printer_service::run() ──► on_printer_data()
|
||||
│
|
||||
▼
|
||||
existing open-source transport → controller prints
|
||||
```
|
||||
|
||||
A Local Port whose name is a full file path makes the spooler write output
|
||||
straight to that file with no "save as" prompt. The exclusive-open test is how
|
||||
job completion is detected — no timers, no guessing.
|
||||
|
||||
`init()` clears the spool directory, so a stale job can never be replayed onto
|
||||
the next connection, and any file present afterwards is by definition new.
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Output: `target\release\printer_driver_adapter.dll` (~140 KB, no dependencies —
|
||||
std only).
|
||||
|
||||
## How the build wires it in
|
||||
|
||||
DVForge does all of this automatically for Windows builds — nothing here needs
|
||||
to be run by hand.
|
||||
|
||||
`builder/orchestrator.py` → `_install_open_printer_adapter()`
|
||||
: cargo-builds this crate and copies the DLL into the Flutter `Release/` folder,
|
||||
overwriting the signature-checked one that `_ensure_windows_printer_driver()`
|
||||
downloaded. It runs before the MSI harvest and the portable packer, so the
|
||||
replacement ships inside both installers.
|
||||
|
||||
`builder/customize.py` → `_apply_printer_port()`
|
||||
: repoints the printer's port at the spool file. **Two** implementations exist
|
||||
and both are patched, because patching only one leaves the printer on the
|
||||
wrong port:
|
||||
|
||||
- `libs/remote_printer/src/lib.rs` — Rust, used by `--install-remote-printer`
|
||||
and the in-app Settings button.
|
||||
- `res/msi/CustomActions/RemotePrinter.cpp` — a full C++ reimplementation used
|
||||
by the MSI's `InstallPrinter` custom action. This is the one that runs during
|
||||
a normal installer run.
|
||||
|
||||
Only the port changes. The printer keeps its name, and the driver stays
|
||||
`RustDesk v4 Printer Driver`.
|
||||
|
||||
It runs after `_apply_appname()`, since both files have the app name substituted
|
||||
into them.
|
||||
|
||||
## Verifying a build
|
||||
|
||||
After installing, confirm the adapter loaded:
|
||||
|
||||
```powershell
|
||||
Get-ChildItem "C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\<App>\log" `
|
||||
-Recurse -Filter *.log | Select-String "printer service"
|
||||
```
|
||||
|
||||
Expect `printer service initialized`. The old failure reads
|
||||
`printer service init failed: Failed to init printer driver`.
|
||||
|
||||
Confirm the port is the spool file, not a named port:
|
||||
|
||||
```powershell
|
||||
Get-Printer -Name "<App> Printer" | Select-Object Name,DriverName,PortName
|
||||
```
|
||||
|
||||
`PortName` should be `C:\ProgramData\<App>\printer-spool\job.prn`.
|
||||
|
||||
Then connect from another machine, print on the controlled one, and check
|
||||
`%ProgramData%\<App>\printer-spool\adapter.log` for a `captured` line and the
|
||||
server log for `Got prn data, data len:`.
|
||||
|
||||
## Which driver to render with
|
||||
|
||||
The controller prints received bytes via `PrintXPSRawData()`
|
||||
(`src/platform/windows.rs:4261`), so the payload must be a valid XPS package.
|
||||
|
||||
- **`RustDesk v4 Printer Driver`** — already installed if you ever ran the app's
|
||||
printer install, and it declares `XpsFormat=XPS` (MS-XPS), which is exactly
|
||||
what the receiving end expects. Note its render filter is signed by *Microsoft*
|
||||
("Windows Hardware Compatibility Publisher"), not RustDesk — the signature gate
|
||||
is only in the adapter, so this driver has no objection to who calls it.
|
||||
Best format match. Check redistribution terms before shipping it yourself.
|
||||
- **`Microsoft XPS Document Writer v4`** — inbox on every Windows machine and
|
||||
unambiguously redistributable, but emits **OpenXPS** (`.oxps`), not MS-XPS.
|
||||
May need conversion before `PrintXPSRawData` accepts it.
|
||||
|
||||
**Resolved 2026-08-19 by inspecting a real captured job.** A test print through
|
||||
`Microsoft XPS Document Writer v4` produced a valid OPC package whose root
|
||||
relationship is:
|
||||
|
||||
Type="http://schemas.openxps.org/oxps/v1.0/fixedrepresentation"
|
||||
|
||||
That is **OpenXPS**, not MS-XPS — confirming the mismatch. Use
|
||||
`RustDesk v4 Printer Driver` instead, which declares `XpsFormat=XPS`. If you must
|
||||
stay on the inbox driver, the fallback is converting OXPS to MS-XPS, or swapping
|
||||
`send_raw_data_to_printer` on the receiving end — which is your own code.
|
||||
|
||||
`PrintXPSRawData` writes diagnostics to `C:\Windows\temp\test_rustdesk.log` on
|
||||
failure; check there if a job arrives but does not print.
|
||||
|
||||
## Status
|
||||
|
||||
| Piece | State |
|
||||
|---|---|
|
||||
| Four-function ABI, exports match upstream | verified |
|
||||
| Capture, completion detection, cleanup | verified by test suite |
|
||||
| Panic safety across the FFI boundary | `catch_unwind` on every export |
|
||||
| Loads and initialises inside the real server | not yet run |
|
||||
| Spooler renders a job into the file port | verified — 37 KB job captured |
|
||||
| End-to-end print to a remote machine | not yet run |
|
||||
| XPS vs OpenXPS payload | resolved — inbox v4 driver emits OpenXPS, use RustDesk's driver |
|
||||
|
||||
## Notes
|
||||
|
||||
`panic = "abort"` is deliberately **not** set. A panic escaping a cdylib into the
|
||||
RustDesk service would take the whole service down, so every export wraps its body
|
||||
in `catch_unwind` instead.
|
||||
|
||||
Licensing: this is clean-room work against an ABI declared in RustDesk's own
|
||||
AGPL-3.0 source. It contains no RustDesk code and defeats no protection on their
|
||||
binary — it replaces it.
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Open drop-in replacement for RustDesk's `printer_driver_adapter.dll`.
|
||||
//!
|
||||
//! RustDesk's own adapter verifies the calling executable's Authenticode
|
||||
//! signature against a hardcoded vendor allow-list, so it refuses to start in
|
||||
//! any custom-branded build. This crate implements the same four-function ABI
|
||||
//! that `src/server/printer_service.rs` loads, with no signature check.
|
||||
//!
|
||||
//! The ABI, as declared upstream:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! pub type Init = fn(tag_name: *const i8) -> i32; // 0 = success
|
||||
//! pub type Uninit = fn();
|
||||
//! pub type GetPrnData = fn(dur_mills: u32, data: *mut *mut i8, data_len: *mut u32);
|
||||
//! pub type FreePrnData = fn(data: *mut i8);
|
||||
//! ```
|
||||
//!
|
||||
//! Symbols are looked up by these exact undecorated names, so every export is
|
||||
//! `#[no_mangle] extern "C"`.
|
||||
//!
|
||||
//! # How capture works
|
||||
//!
|
||||
//! Instead of a custom print driver, this pairs with a printer built on an
|
||||
//! inbox (already Microsoft-signed) driver whose port is a plain file path
|
||||
//! under our spool directory. The spooler writes the rendered job there; we
|
||||
//! pick it up, hand it to RustDesk, and delete it.
|
||||
//!
|
||||
//! # Safety
|
||||
//!
|
||||
//! Every export catches panics. Unwinding across an FFI boundary is undefined
|
||||
//! behaviour, and a panic escaping into the RustDesk server process would take
|
||||
//! the whole service down.
|
||||
|
||||
use std::alloc::{alloc, dealloc, Layout};
|
||||
use std::ffi::CStr;
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::raw::c_char;
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::ptr;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Bytes reserved before each returned buffer to stash its length.
|
||||
/// `free_prn_data` only receives a pointer, so the length has to travel with
|
||||
/// the allocation itself.
|
||||
const HEADER: usize = 16;
|
||||
const ALIGN: usize = 8;
|
||||
|
||||
/// Windows share mode: 0 means "no other handle may be open". Opening the
|
||||
/// spool file this way is how we tell that the spooler has finished with it.
|
||||
const SHARE_NONE: u32 = 0;
|
||||
|
||||
struct State {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
static STATE: Mutex<Option<State>> = Mutex::new(None);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Append a line to `<spool>/adapter.log`. Never fails the caller: if logging
|
||||
/// breaks there is nothing useful to do about it from inside a print filter.
|
||||
fn log(dir: &Path, msg: &str) {
|
||||
let path = dir.join("adapter.log");
|
||||
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(f, "[{}] {}", now_secs(), msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip characters that cannot appear in a Windows path component, so an
|
||||
/// arbitrary app name can be used as a directory name.
|
||||
fn sanitize(tag: &str) -> String {
|
||||
let cleaned: String = tag
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
|
||||
c if (c as u32) < 0x20 => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
let trimmed = cleaned.trim().trim_end_matches('.').to_string();
|
||||
if trimmed.is_empty() {
|
||||
"RustDeskPrinter".to_string()
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
/// `%ProgramData%\<tag>\printer-spool`, the directory the printer port writes
|
||||
/// into. ProgramData is used rather than a user profile because the spooler
|
||||
/// runs as a service account.
|
||||
fn spool_dir(tag: &str) -> PathBuf {
|
||||
let base = std::env::var("ProgramData")
|
||||
.or_else(|_| std::env::var("ALLUSERSPROFILE"))
|
||||
.unwrap_or_else(|_| "C:\\ProgramData".to_string());
|
||||
Path::new(&base).join(sanitize(tag)).join("printer-spool")
|
||||
}
|
||||
|
||||
/// True when the file can be opened with no sharing, i.e. the spooler has
|
||||
/// closed its handle and the job is complete.
|
||||
fn read_if_complete(path: &Path) -> Option<Vec<u8>> {
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.share_mode(SHARE_NONE)
|
||||
.open(path)
|
||||
.ok()?;
|
||||
let mut buf = Vec::new();
|
||||
f.read_to_end(&mut buf).ok()?;
|
||||
if buf.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
/// Oldest candidate job in the spool directory, by modification time.
|
||||
/// `adapter.log` and zero-length files are skipped.
|
||||
fn oldest_job(dir: &Path) -> Option<PathBuf> {
|
||||
let mut best: Option<(SystemTime, PathBuf)> = None;
|
||||
for entry in fs::read_dir(dir).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) if m.is_file() && m.len() > 0 => m,
|
||||
_ => continue,
|
||||
};
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.eq_ignore_ascii_case("adapter.log"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mtime = meta.modified().unwrap_or(UNIX_EPOCH);
|
||||
match &best {
|
||||
Some((t, _)) if *t <= mtime => {}
|
||||
_ => best = Some((mtime, path)),
|
||||
}
|
||||
}
|
||||
best.map(|(_, p)| p)
|
||||
}
|
||||
|
||||
/// Copy `bytes` into a buffer this crate owns, with its length recorded in a
|
||||
/// header so `free_prn_data` can reconstruct the exact layout.
|
||||
unsafe fn alloc_buf(bytes: &[u8]) -> *mut c_char {
|
||||
let total = HEADER + bytes.len();
|
||||
let layout = match Layout::from_size_align(total, ALIGN) {
|
||||
Ok(l) => l,
|
||||
Err(_) => return ptr::null_mut(),
|
||||
};
|
||||
let base = alloc(layout);
|
||||
if base.is_null() {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
(base as *mut u64).write_unaligned(bytes.len() as u64);
|
||||
ptr::copy_nonoverlapping(bytes.as_ptr(), base.add(HEADER), bytes.len());
|
||||
base.add(HEADER) as *mut c_char
|
||||
}
|
||||
|
||||
unsafe fn free_buf(data: *mut c_char) {
|
||||
if data.is_null() {
|
||||
return;
|
||||
}
|
||||
let base = (data as *mut u8).sub(HEADER);
|
||||
let len = (base as *mut u64).read_unaligned() as usize;
|
||||
if let Ok(layout) = Layout::from_size_align(HEADER + len, ALIGN) {
|
||||
dealloc(base, layout);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// exported ABI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Prepare the spool directory for `tag_name` (RustDesk passes the app name).
|
||||
/// Returns 0 on success, non-zero on failure — upstream turns any non-zero
|
||||
/// into "Failed to init printer driver".
|
||||
#[no_mangle]
|
||||
pub extern "C" fn init(tag_name: *const c_char) -> i32 {
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
if tag_name.is_null() {
|
||||
return 1;
|
||||
}
|
||||
let tag = match unsafe { CStr::from_ptr(tag_name) }.to_str() {
|
||||
Ok(t) => t.to_string(),
|
||||
Err(_) => return 2,
|
||||
};
|
||||
let dir = spool_dir(&tag);
|
||||
if fs::create_dir_all(&dir).is_err() {
|
||||
return 3;
|
||||
}
|
||||
// Drop anything left from a previous run so a stale job cannot be
|
||||
// replayed onto the next connection.
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_file()
|
||||
&& !p
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.eq_ignore_ascii_case("adapter.log"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
log(&dir, &format!("init tag={:?} dir={}", tag, dir.display()));
|
||||
match STATE.lock() {
|
||||
Ok(mut g) => {
|
||||
*g = Some(State { dir });
|
||||
0
|
||||
}
|
||||
Err(_) => 4,
|
||||
}
|
||||
});
|
||||
result.unwrap_or(5)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn uninit() {
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
if let Ok(mut g) = STATE.lock() {
|
||||
if let Some(s) = g.as_ref() {
|
||||
log(&s.dir, "uninit");
|
||||
}
|
||||
*g = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Hand back one completed print job, or nothing.
|
||||
///
|
||||
/// `dur_mills` is advisory upstream ("data generated in the last N ms"); the
|
||||
/// spool directory is cleared on `init`, so anything present is by definition
|
||||
/// new and is returned regardless of age. That avoids dropping a job when the
|
||||
/// poll loop is delayed.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_prn_data(_dur_mills: u32, data: *mut *mut c_char, data_len: *mut u32) {
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
unsafe {
|
||||
if !data.is_null() {
|
||||
*data = ptr::null_mut();
|
||||
}
|
||||
if !data_len.is_null() {
|
||||
*data_len = 0;
|
||||
}
|
||||
}
|
||||
if data.is_null() || data_len.is_null() {
|
||||
return;
|
||||
}
|
||||
let guard = match STATE.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return,
|
||||
};
|
||||
let Some(state) = guard.as_ref() else { return };
|
||||
let Some(job) = oldest_job(&state.dir) else { return };
|
||||
// Still open by the spooler means the job is mid-render; try next tick.
|
||||
let Some(bytes) = read_if_complete(&job) else { return };
|
||||
let _ = fs::remove_file(&job);
|
||||
unsafe {
|
||||
let buf = alloc_buf(&bytes);
|
||||
if buf.is_null() {
|
||||
log(&state.dir, "alloc failed, job dropped");
|
||||
return;
|
||||
}
|
||||
*data = buf;
|
||||
*data_len = bytes.len() as u32;
|
||||
}
|
||||
log(
|
||||
&state.dir,
|
||||
&format!("captured {} ({} bytes)", job.display(), bytes.len()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn free_prn_data(data: *mut c_char) {
|
||||
let _ = std::panic::catch_unwind(|| unsafe { free_buf(data) });
|
||||
}
|
||||
Reference in New Issue
Block a user