Initial commit: rdgen with GitHub/Gitea dual build backend

This commit is contained in:
naeeo
2026-09-29 19:56:02 +08:00
commit a3686a49d4
103 changed files with 20007 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
target/
+7
View File
@@ -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"
+16
View File
@@ -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]
+186
View File
@@ -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.
+289
View File
@@ -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) });
}