diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20b5f28..1dac1b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,15 +52,15 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Build (all targets) - run: cargo build --all-targets --verbose + run: cargo build --all-targets --all-features --verbose - name: Clippy run: cargo clippy --all-targets -- -D warnings - - name: Run tests + - name: Run unit tests env: LD_LIBRARY_PATH: /usr/lib:/usr/local/lib - run: cargo test --all --verbose -- --nocapture + run: cargo test --all-targets --all-features --verbose - name: D-Bus smoke test (ignored integration tests) env: @@ -72,7 +72,7 @@ jobs: run: dbus-launch --exit-with-session cargo test --test integration -- --ignored --nocapture - name: Build examples - run: cargo build --examples --verbose + run: cargo build --examples --all-features --verbose docs: name: Documentation @@ -99,7 +99,7 @@ jobs: - name: Docs env: RUSTDOCFLAGS: -D warnings - run: cargo doc --no-deps -p cpdb-rs + run: cargo doc --no-deps -p cpdb-rs --all-features audit: name: Security audit @@ -195,4 +195,6 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Build Rust library (bindgen + compile, no link) - run: cargo build --lib --verbose + run: cargo build --lib --all-features --verbose + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d0cabf..ec3200e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** The C-FFI interface is behind the `ffi` feature flag and has been moved to the underlying `cpdb-sys` crate. The default feature is now `zbus-backend`. - **BREAKING:** `Printer` now carries a lifetime parameter tied to its `Frontend`. Borrowed printers cannot outlive their frontend — the borrow checker enforces this. `Printer::load_from_file` returns a `Printer<'static>`. @@ -54,6 +55,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `CpdbClient`: The new main entrypoint for the async native D-Bus client. +- `CpdbClient::get_all_printers`: Fetches a snapshot of all active printers across all discovered backends. +- `CpdbClient::get_printer_details`: Fetches options and media sizes for a specific printer. +- `CpdbClient::discovery_stream`: Exposes a native `Stream` of `DiscoveryEvent`s (`PrinterAdded`, `PrinterRemoved`, `PrinterStateChanged`) for live discovery. +- `CpdbClient::keep_alive_all`: Helper method to ping all connected backends and prevent them from auto-exiting due to inactivity timeouts. - `Frontend::new_with_observer` — closure-based registration for the `cpdb_printer_callback`. Backed by a process-global pointer-keyed registry and unregistered automatically when @@ -101,6 +107,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Infrastructure +- Native Rust D-Bus Client: Completely rewrote the crate to use native Rust D-Bus bindings via `zbus`, dropping the dependency on the `cpdb-libs` C library for the primary API. +- Async API: All frontend APIs are now fully asynchronous and powered by `tokio`. +- Legacy FFI: The old C-FFI bindings are still available but have been moved behind the optional `ffi` feature flag. - `build.rs` now prefers `pkg-config` over the hard-coded fallback path list, drops the architecture-specific `/usr/lib/x86_64-linux-gnu` guess, and emits a `cargo:warning` when neither pkg-config nor `CPDB_LIBS_PATH` diff --git a/Cargo.lock b/Cargo.lock index 2a68530..c2403dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,46 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bindgen" version = "0.72.1" @@ -39,9 +79,21 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cexpr" @@ -54,9 +106,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.7" +version = "0.20.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" dependencies = [ "smallvec", "target-lexicon", @@ -79,24 +131,81 @@ dependencies = [ "libloading", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cpdb-rs" version = "0.1.0" dependencies = [ - "bindgen", + "cpdb-sys", + "futures-util", "glib-sys", "libc", "log", - "pkg-config", + "serde", + "serde_json", "tempfile", "thiserror", + "tokio", + "zbus", +] + +[[package]] +name = "cpdb-sys" +version = "0.1.0" +dependencies = [ + "bindgen", + "glib-sys", + "libc", + "pkg-config", + "thiserror", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "equivalent" @@ -114,11 +223,32 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "foldhash" @@ -126,6 +256,61 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -166,9 +351,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -176,6 +361,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "id-arena" version = "2.3.0" @@ -184,12 +375,12 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -205,9 +396,21 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] [[package]] name = "leb128fmt" @@ -245,9 +448,18 @@ checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memoffset" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] [[package]] name = "minimal-lexical" @@ -255,6 +467,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + [[package]] name = "nom" version = "7.1.3" @@ -267,9 +490,31 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" @@ -287,6 +532,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -342,9 +596,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" @@ -359,11 +613,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -372,6 +632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -396,9 +657,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -407,11 +668,22 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -422,12 +694,38 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "syn" version = "2.0.117" @@ -441,9 +739,9 @@ dependencies = [ [[package]] name = "system-deps" -version = "7.0.7" +version = "7.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" dependencies = [ "cfg-expr", "heck", @@ -454,9 +752,9 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tempfile" @@ -491,11 +789,39 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", @@ -508,27 +834,81 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys", +] [[package]] name = "unicode-ident" @@ -542,19 +922,36 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "version-compare" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", ] [[package]] @@ -563,7 +960,52 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", ] [[package]] @@ -617,9 +1059,18 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wit-bindgen" @@ -709,8 +1160,104 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 891060e..cdeef5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = ["cpdb-sys"] + [package] name = "cpdb-rs" version = "0.1.0" @@ -9,8 +12,6 @@ categories = ["api-bindings", "os::unix-apis"] readme = "README.md" repository = "https://github.com/OpenPrinting/cpdb-rs" rust-version = "1.85" -build = "build.rs" -links = "cpdb" [package.metadata.docs.rs] all-features = false @@ -19,14 +20,59 @@ targets = ["x86_64-unknown-linux-gnu"] rustdoc-args = ["--cfg", "docsrs"] [dependencies] -libc = "0.2" thiserror = "2.0" log = "0.4" -glib-sys = "0.22" +serde = { version = "1.0", features = ["derive"] } +zbus = { version = "5.15.0", default-features = false, features = [ + "tokio", +], optional = true } +futures-util = "0.3" +tokio = "1" +cpdb-sys = { path = "cpdb-sys", version = "0.1.0", optional = true } -[build-dependencies] -bindgen = "0.72" -pkg-config = "0.3" +[features] +default = ["zbus-backend"] +ffi = ["dep:cpdb-sys"] +zbus-backend = ["dep:zbus"] [dev-dependencies] tempfile = "3.27" +serde_json = "1.0" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +libc = "0.2" +glib-sys = "0.22" + +[[example]] +name = "ffi_basic_usage" +path = "examples/ffi_basic_usage.rs" +required-features = ["ffi"] + +[[example]] +name = "ffi_cli_printer_manager" +path = "examples/ffi_cli_printer_manager.rs" +required-features = ["ffi"] + +[[example]] +name = "cpdb_text_frontend" +path = "examples/cpdb-text-frontend.rs" +required-features = ["ffi"] + +[[example]] +name = "zbus_test" +path = "examples/zbus_test.rs" +required-features = ["zbus-backend"] + +[[example]] +name = "filter_printers" +path = "examples/filter_printers.rs" +required-features = ["zbus-backend"] + +[[example]] +name = "get_translations" +path = "examples/get_translations.rs" +required-features = ["zbus-backend"] + +[[example]] +name = "print_a_document" +path = "examples/print_a_document.rs" +required-features = ["zbus-backend"] diff --git a/README.md b/README.md index 8610b75..df27b52 100644 --- a/README.md +++ b/README.md @@ -4,80 +4,63 @@ [![Documentation](https://docs.rs/cpdb-rs/badge.svg)](https://docs.rs/cpdb-rs) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -Safe Rust bindings for the Common Print Dialog Backends -([`cpdb-libs`](https://github.com/OpenPrinting/cpdb-libs)) library from -OpenPrinting. +Safe, native Rust async bindings for the Common Print Dialog Backends ([CPDB](https://github.com/OpenPrinting/cpdb-libs)) D-Bus interface. ## Overview -cpdb-rs lets Rust applications drive cpdb-libs over D-Bus: discover -printers, inspect their options and translations, and submit print jobs. -The crate is built around safe owning/borrowing types and `Result`-based -error handling on top of bindgen-generated FFI. +cpdb-rs lets Rust applications communicate with CPDB print backends (like `cpdb-backend-cups`) directly over D-Bus without requiring any C dependencies. + +The library uses [`zbus`](https://crates.io/crates/zbus) and [`tokio`](https://crates.io/crates/tokio) to provide a fully asynchronous, memory-safe, and pure-Rust implementation of the CPDB client protocol. ## Features -- **Printer discovery** over D-Bus -- **Job submission** with per-job options and titles -- **Settings management** — global (`Settings`) and per-printer -- **Option & translation lookup**, including localised labels -- **Media information** — sizes and per-media margin tables -- **Memory-safe** — owned/borrowed split enforced by lifetimes -- **Linux-first**; macOS supports a headers-only verification build +- **Pure Rust D-Bus Client:** No `libcpdb-dev` or C compiler needed! Everything runs natively over D-Bus using `zbus`. +- **Async First:** All methods are `async` and powered by Tokio. +- **Live Discovery:** Subscribe to a native Rust `Stream` for real-time printer additions, removals, and state changes. +- **Activation Retry:** Gracefully retries printer discovery to handle D-Bus activation race conditions. +- **Keep-Alive Management:** Automatically pings backends to keep them active in the background. +- *(Optional)* **Legacy C-FFI bindings** available via the `ffi` feature flag. ## Prerequisites -### System dependencies +Because `cpdb-rs` communicates directly over D-Bus, you do not need to install the `cpdb-libs` C development headers to build this project. -Install the cpdb-libs C library and GLib headers. +However, your system must have CPDB backends installed to actually discover any printers: ```bash # Debian / Ubuntu -sudo apt-get install libcpdb-dev libglib2.0-dev +sudo apt-get install cpdb-backend-cups # Fedora / RHEL / CentOS -sudo dnf install cpdb-libs-devel glib2-devel -``` - -Building from source: - -```bash -git clone https://github.com/OpenPrinting/cpdb-libs.git -cd cpdb-libs -./autogen.sh -./configure --prefix=/usr -make -j"$(nproc)" -sudo make install -sudo ldconfig +sudo dnf install cpdb-backend-cups ``` -### Rust - -Rust 1.85+ (2024 edition) is required. - ## Installation ```toml [dependencies] cpdb-rs = "0.1.0" +tokio = { version = "1.0", features = ["full"] } ``` ## Quick start ```rust -use cpdb_rs::{Frontend, init}; - -fn main() -> cpdb_rs::Result<()> { - init(); - - let frontend = Frontend::new()?; - frontend.connect_to_dbus()?; - - for printer in frontend.get_printers()? { - println!("Printer: {}", printer.name()?); - println!(" Backend: {}", printer.backend_name()?); - println!(" State: {}", printer.get_updated_state()?); - println!(" Accepts: {}", printer.is_accepting_jobs()?); +use cpdb_rs::CpdbClient; + +#[tokio::main] +async fn main() -> cpdb_rs::Result<()> { + // Connect to D-Bus and auto-activate available CPDB backends + let client = CpdbClient::new().await?; + println!("Connected to {} backend(s).\n", client.backend_count()); + + // Retrieve all active printers + let printers = client.get_all_printers().await?; + for p in &printers { + println!("Printer: {} (ID: {})", p.name, p.id); + println!(" Make & Model: {}", p.make_model); + println!(" State: {}", p.state); + println!(" Accepts Jobs: {}", p.accepting_jobs); } Ok(()) @@ -86,154 +69,95 @@ fn main() -> cpdb_rs::Result<()> { ## Examples -### Printer discovery +### Fetching Printer Options and Media Sizes ```rust -use cpdb_rs::{Frontend, init}; - -fn list_printers() -> cpdb_rs::Result<()> { - init(); - let frontend = Frontend::new()?; - frontend.connect_to_dbus()?; - for printer in frontend.get_printers()? { - println!("Name: {}", printer.name()?); - println!("Location: {}", printer.location()?); - println!("Description: {}", printer.description()?); - println!("Make & Model: {}", printer.make_and_model()?); - } - Ok(()) -} -``` +use cpdb_rs::CpdbClient; -### Observing printer events +#[tokio::main] +async fn main() -> cpdb_rs::Result<()> { + let client = CpdbClient::new().await?; + let printers = client.get_all_printers().await?; -```rust -use cpdb_rs::{Frontend, PrinterUpdate, init}; -use std::time::Duration; - -fn watch() -> cpdb_rs::Result<()> { - init(); - let frontend = Frontend::new_with_observer(|printer, update| { - let name = printer.name().unwrap_or_default(); - match update { - PrinterUpdate::Added => println!("+ {name}"), - PrinterUpdate::Removed => println!("- {name}"), - PrinterUpdate::StateChanged => println!("~ {name}"), + if let Some(p) = printers.first() { + // Fetch specific details using the printer's ID and backend name + let (options, media) = client.get_printer_details(&p.id, &p.backend).await?; + + println!("Options for {}:", p.name); + for opt in options { + println!(" {}: default='{}', choices=[{}]", + opt.name, opt.default_value, opt.supported_values.join(", ")); } - })?; - frontend.connect_to_dbus()?; - // Keep the frontend alive while the D-Bus thread delivers events. - std::thread::sleep(Duration::from_secs(30)); + } Ok(()) } ``` -### Looking up a specific printer - -```rust -use cpdb_rs::{Frontend, init}; - -fn find_one() -> cpdb_rs::Result<()> { - init(); - let frontend = Frontend::new()?; - frontend.connect_to_dbus()?; - - // By (id, backend) — the canonical lookup; O(1) inside cpdb-libs. - let p = frontend.find_printer("HP_LaserJet_4", "CUPS")?; - println!("found {} on {}", p.name()?, p.backend_name()?); - Ok(()) -} -``` +### Live Discovery Event Stream -### Print job submission +Watch for new printers appearing and disappearing in real-time. ```rust -use cpdb_rs::{Frontend, init}; - -fn submit(printer_name: &str, file_path: &str) -> cpdb_rs::Result<()> { - init(); - let frontend = Frontend::new()?; - frontend.connect_to_dbus()?; - - let printer = frontend.get_printer(printer_name)?; - - // No-options print. - let job_id = printer.print_file(file_path)?; - println!("job: {job_id}"); - - // With options and a title — options are applied to the printer's - // setting table before submission. - let job_id = printer.submit_job( - file_path, - &[("copies", "2"), ("sides", "two-sided-long-edge")], - "My Job", - )?; - println!("job: {job_id}"); - Ok(()) -} -``` - -### Settings persistence +use cpdb_rs::{CpdbClient, DiscoveryEvent}; +use futures_util::StreamExt; + +#[tokio::main] +async fn main() -> cpdb_rs::Result<()> { + let client = CpdbClient::new().await?; + + // Spawn a background task to keep the backends from automatically + // timing out and exiting after 30 seconds of inactivity. + let keep_alive_client = client.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(15)); + loop { + interval.tick().await; + keep_alive_client.keep_alive_all().await; + } + }); + + let mut stream = client.discovery_stream().await?; + println!("Listening for printer changes..."); + + while let Some(event) = stream.next().await { + match event { + DiscoveryEvent::PrinterAdded(snap) => { + println!("+ Added: {} ({})", snap.name, snap.backend); + } + DiscoveryEvent::PrinterRemoved { id, backend } => { + println!("- Removed: {} ({})", id, backend); + } + DiscoveryEvent::PrinterStateChanged { id, state, accepting_jobs, .. } => { + println!("~ State Changed: {} is now {} (accepting={})", id, state, accepting_jobs); + } + } + } -```rust -use cpdb_rs::{Settings, init}; - -fn manage() -> cpdb_rs::Result<()> { - init(); - let mut s = Settings::new()?; - s.add_setting("copies", "1")?; - s.add_setting("orientation-requested", "portrait")?; - s.add_setting("media", "A4")?; - - // Persists to the cpdb-managed user config directory. - s.save_to_disk()?; - let _loaded = Settings::read_from_disk()?; Ok(()) } ``` -### Options and translations - -```rust -use cpdb_rs::{Frontend, init}; - -fn details(printer_name: &str) -> cpdb_rs::Result<()> { - init(); - let frontend = Frontend::new()?; - frontend.connect_to_dbus()?; - - let p = frontend.get_printer(printer_name)?; +You can run the full interactive test example using: - println!("default copies: {:?}", p.get_default("copies")?); - println!("current quality: {:?}", p.get_current("print-quality")?); - - let size = p.get_media_size("iso_a4_210x297mm")?; - println!("A4: {} x {} (1/100 mm)", size.width, size.length); - - if let Some(label) = p.get_option_translation("copies", "en_US")? { - println!("option label: {label}"); - } - if let Some(label) = p.get_choice_translation("sides", "two-sided-long-edge", "en_US")? { - println!("choice label: {label}"); - } - Ok(()) -} +```bash +cargo run --example zbus_test ``` -## CLI examples +## Architecture (zbus Backend) -```bash -# Basic usage — list printers, check version, submit a tiny file -cargo run --example basic_usage +Instead of linking against `libcpdb.so` and using `bindgen` (which required unsafe C memory management, callbacks, and manual lifetime tracking), `cpdb-rs` now uses `zbus` to speak the D-Bus protocol directly to the print backends. This provides massive benefits: -# Interactive CLI — list, inspect, configure printers -cargo run --example cli_printer_manager +- **100% Safe Rust:** No raw pointers, no manual memory management, no undefined behavior. +- **Zero C Dependencies:** You don't need `libcpdb-dev` to compile. +- **Async Tokio Integration:** `zbus` integrates perfectly with Tokio, allowing you to await D-Bus calls and use Rust `Stream`s for live discovery events. +- **Activation Retries:** Automatically retries initial calls to handle `UnknownMethod` race conditions when systemd auto-activates D-Bus backends. -# Full cpdb-text-frontend port — every cpdb-rs API exercised -cargo run --example cpdb-text-frontend -``` +## Legacy Architecture (C-FFI) -## Architecture +> [!WARNING] +> The C-FFI interface is behind the `ffi` feature flag and has been moved to the underlying `cpdb-sys` crate. The default feature is now `zbus-backend`. To continue using `cpdb_rs::Frontend`, update your `Cargo.toml` to: `cpdb-rs = { default-features = false, features = ["ffi"] }`. + +If you have legacy code that still requires the synchronous C-FFI wrappers around `cpdb-libs`, they are still available by enabling the `ffi` feature flag in your `Cargo.toml`. See the `ffi` module documentation for details. ``` ┌───────────────────────────────────────────┐ @@ -268,7 +192,7 @@ cargo run --example cpdb-text-frontend ~/.config/cpdb/ (cpdb-libs-managed location) ``` -### Two `add_setting` methods, two scopes +### Two `add_setting` methods, two scopes (C-FFI) | Method | Scope | Persists across runs? | |---------------------------|----------------------------------------------------|-------------------------------------| @@ -283,18 +207,22 @@ serialisable view that cpdb-libs reads back from disk on startup. | Module | What lives here | |-----------------------|----------------------------------------------------------------------| -| `cpdb_rs::frontend` | `Frontend` — D-Bus lifecycle, printer discovery, default printer | -| `cpdb_rs::printer` | `Printer`, `Margin/Margins`, `MediaSize`, `TranslationMap`, | -| | `PrintFdHandle`, `PrintSocketHandle` | -| `cpdb_rs::settings` | `Settings`, `Options`, `Media` | -| `cpdb_rs::options` | `OptionInfo`, `OptionsCollection` (owned snapshot of cpdb_options_t)| -| `cpdb_rs::callbacks` | Closure trampolines + `PrinterUpdate` enum | -| `cpdb_rs::common` | `init`, `version`, path/config helpers | +| `cpdb_rs::client` | **(zbus)** `CpdbClient` — Main async D-Bus client & discovery logic | +| `cpdb_rs::events` | **(zbus)** `DiscoveryEvent`, `PrinterSnapshot` for async streams | +| `cpdb_rs::media` | **(zbus)** `MediaCollection`, `MediaInfo`, `MarginInfo` | +| `cpdb_rs::config` | **(zbus)** `PrinterConfig` for job submission configuration | +| `cpdb_rs::options` | `OptionInfo`, `OptionsCollection` (shared across both implementations)| | `cpdb_rs::error` | `CpdbError` and the crate-wide `Result` alias | -| `cpdb_rs::util` | Internal `CStr` helpers + the `COptions` C-array builder | -| `cpdb_rs::ffi` | Raw bindgen output; everything `unsafe` | - -## Ownership model +| `cpdb_rs::proxy` | **(zbus)** Auto-generated zbus proxy trait `PrintBackend` | +| `cpdb_rs::frontend` | *(ffi)* `Frontend` — D-Bus lifecycle, discovery, default printer | +| `cpdb_rs::printer` | *(ffi)* `Printer`, `Margin/Margins`, `MediaSize`, `TranslationMap` | +| `cpdb_rs::settings` | *(ffi)* `Settings`, `Options`, `Media` | +| `cpdb_rs::callbacks` | *(ffi)* Closure trampolines + `PrinterUpdate` enum | +| `cpdb_rs::common` | *(ffi)* `init`, `version`, path/config helpers | +| `cpdb_rs::util` | *(ffi)* Internal `CStr` helpers + the `COptions` C-array builder | +| `cpdb_rs::ffi` | *(ffi)* Raw bindgen output; everything `unsafe` | + +## Ownership model (C-FFI) `Printer` carries a lifetime tied to the `Frontend` it came from. Borrowed printers (those returned by `get_printers`, `get_printer`, `find_printer`, diff --git a/cpdb-sys/Cargo.toml b/cpdb-sys/Cargo.toml new file mode 100644 index 0000000..b8c8034 --- /dev/null +++ b/cpdb-sys/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cpdb-sys" +version = "0.1.0" +edition = "2024" +description = "FFI bindings for cpdb-libs" +license = "MIT" +build = "build.rs" +links = "cpdb" + +[dependencies] +libc = "0.2" +glib-sys = "0.22" +thiserror = "2.0" + +[build-dependencies] +bindgen = "0.72" +pkg-config = "0.3" diff --git a/build.rs b/cpdb-sys/build.rs similarity index 100% rename from build.rs rename to cpdb-sys/build.rs diff --git a/cpdb-sys/include/wrapper.h b/cpdb-sys/include/wrapper.h new file mode 100644 index 0000000..445df55 --- /dev/null +++ b/cpdb-sys/include/wrapper.h @@ -0,0 +1,8 @@ +#ifndef WRAPPER_H +#define WRAPPER_H +#include +#include +#include +#include +#include +#endif // WRAPPER_H \ No newline at end of file diff --git a/src/ffi.rs b/cpdb-sys/src/bindings.rs similarity index 100% rename from src/ffi.rs rename to cpdb-sys/src/bindings.rs diff --git a/src/callbacks.rs b/cpdb-sys/src/callbacks.rs similarity index 99% rename from src/callbacks.rs rename to cpdb-sys/src/callbacks.rs index 322fddc..9f7d85d 100644 --- a/src/callbacks.rs +++ b/cpdb-sys/src/callbacks.rs @@ -14,7 +14,7 @@ //! Both trampolines wrap the user closure in `catch_unwind` so a Rust //! panic does not unwind across the FFI boundary (which is UB). -use crate::ffi; +use crate::bindings as ffi; use crate::printer::Printer; use std::collections::HashMap; use std::panic::AssertUnwindSafe; diff --git a/src/common.rs b/cpdb-sys/src/common.rs similarity index 98% rename from src/common.rs rename to cpdb-sys/src/common.rs index a1ee5a8..236be46 100644 --- a/src/common.rs +++ b/cpdb-sys/src/common.rs @@ -1,9 +1,9 @@ //! Library-wide entry points: version query, one-shot initialisation, //! and the small set of path/config helpers cpdb-libs ships. +use super::bindings as ffi; +use super::util; use crate::error::{CpdbError, Result}; -use crate::ffi; -use crate::util; use std::ffi::CString; /// Returns the version of the linked cpdb-libs C library. diff --git a/cpdb-sys/src/error.rs b/cpdb-sys/src/error.rs new file mode 100644 index 0000000..7466a5c --- /dev/null +++ b/cpdb-sys/src/error.rs @@ -0,0 +1,66 @@ +//! `cpdb-sys` workspace error type and `Result` alias. + +use std::ffi::NulError; +use std::str::Utf8Error; +use thiserror::Error; + +/// Errors that originate from the cpdb-rs bindings. +/// +/// This type is `#[non_exhaustive]` — match arms must include a wildcard +/// so adding variants in future minor releases is not a breaking change. +#[non_exhaustive] +#[derive(Error, Debug)] +pub enum CpdbError { + /// A C function returned `NULL` where a valid pointer was required. + #[error("Null pointer encountered")] + NullPointer, + + /// A printer object pointer is invalid or has been released. + #[error("Invalid printer object")] + InvalidPrinter, + + /// A lookup (printer, option, media, translation, ...) returned no result. + #[error("Not found: {0}")] + NotFound(String), + + /// A printer-side operation failed (set default, accept jobs, ...). + #[error("Printer error: {0}")] + PrinterError(String), + + /// A print job submission failed. + #[error("Print job failed: {0}")] + JobFailed(String), + + /// A backend-side operation failed. + #[error("Backend error: {0}")] + BackendError(String), + + /// A frontend-side operation failed (D-Bus, lifecycle, ...). + #[error("Frontend error: {0}")] + FrontendError(String), + + /// A printer option could not be parsed or applied. + #[error("Option error: {0}")] + OptionError(String), + + /// A C string returned by cpdb-libs contained invalid UTF-8. + #[error("Invalid UTF-8 string: {0}")] + Utf8Error(#[from] Utf8Error), + + /// A Rust string contained an interior NUL byte. + #[error("Nul byte in string: {0}")] + NulError(#[from] NulError), + + /// An I/O error bubbled up from std::io. + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + /// An unexpected status code was returned. + #[error("Invalid status code: {0}")] + InvalidStatus(i32), + /// The requested operation is not supported. + #[error("Unsupported operation")] + Unsupported, +} + +/// Shorthand `Result` alias used throughout the crate. +pub type Result = std::result::Result; diff --git a/src/frontend.rs b/cpdb-sys/src/frontend.rs similarity index 99% rename from src/frontend.rs rename to cpdb-sys/src/frontend.rs index 85f87d4..0db4a04 100644 --- a/src/frontend.rs +++ b/cpdb-sys/src/frontend.rs @@ -11,10 +11,10 @@ //! cpdb-libs does not lock internally. If you need concurrent access, //! wrap the frontend in a [`std::sync::Mutex`]. -use crate::callbacks::{self, PrinterUpdate}; +use super::bindings as ffi; +use super::callbacks::{self, PrinterUpdate}; +use super::printer::Printer; use crate::error::{CpdbError, Result}; -use crate::ffi; -use crate::printer::Printer; use std::ffi::{CStr, CString}; use std::mem::MaybeUninit; use std::ptr::NonNull; diff --git a/cpdb-sys/src/lib.rs b/cpdb-sys/src/lib.rs new file mode 100644 index 0000000..63bb13e --- /dev/null +++ b/cpdb-sys/src/lib.rs @@ -0,0 +1,76 @@ +//! Raw FFI bindings for the [cpdb-libs](https://github.com/OpenPrinting/cpdb-libs) +//! C library (Common Print Dialog Backends). +//! +//! # Overview +//! +//! `cpdb-sys` is the low-level foundation of the `cpdb-rs` workspace. It +//! uses [`bindgen`](https://crates.io/crates/bindgen) to generate Rust +//! declarations for every C function, type, and constant exposed by +//! `libcpdb` and `libcpdb-frontend`, and wraps them in thin, safe-ish +//! Rust modules. +//! +//! **Most Rust users should depend on [`cpdb-rs`](https://crates.io/crates/cpdb-rs) +//! instead**, either with the `zbus-backend` feature (async, pure-Rust, +//! zero C dependencies) or the `ffi` feature (which re-exports this crate). +//! Reach for `cpdb-sys` directly only when you need access to a C symbol +//! that the higher-level crate hasn't wrapped yet. +//! +//! # Module structure +//! +//! | Module | Contents | +//! |--------|----------| +//! | [`bindings`] | Raw auto-generated `bindgen` output - all `unsafe` | +//! | [`callbacks`] | Safe closure trampolines for `cpdb_printer_callback` and `cpdb_async_callback` | +//! | [`common`] | Helpers for paths, config dirs, and library init (`cpdbInit`) | +//! | [`error`] | [`error::CpdbError`] enum and `Result` alias used by all modules | +//! | [`frontend`] | Safe wrapper around `cpdb_frontend_obj_t` | +//! | [`options`] | [`options::OptionsCollection`] - owned snapshot of a printer's capabilities | +//! | [`printer`] | Safe wrapper around `cpdb_printer_obj_t` | +//! | [`settings`] | Safe wrapper around `cpdb_settings_t` | +//! | [`util`] | Internal C-string helpers and `COptions` array builder | +//! +//! All raw C symbols (functions, types, constants) are additionally +//! re-exported at the crate root via `pub use bindings::*`, so you can +//! write `cpdb_sys::cpdbInit()` instead of `cpdb_sys::bindings::cpdbInit()`. +//! +//! # Safety +//! +//! Everything in [`bindings`] is `unsafe`. The higher-level modules +//! (`frontend`, `printer`, `settings`, ...) encapsulate the most common +//! usage patterns behind safe APIs, but they cannot prevent all misuse - +//! read each module's documentation carefully before calling into the C +//! library directly. +//! +//! # Build requirements +//! +//! `cpdb-sys` links against `libcpdb` and `libcpdb-frontend`. Install +//! the development headers before building: +//! +//! ```text +//! # Debian / Ubuntu +//! sudo apt install libcpdb-dev +//! +//! # Fedora / RHEL +//! sudo dnf install cpdb-libs-devel +//! ``` +//! +//! Set `CPDB_LIBS_PATH=` if the library is installed to a +//! non-standard location where `pkg-config` cannot find it. + +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![allow(missing_docs)] + +pub mod bindings; +pub mod callbacks; +pub mod common; +pub mod error; +pub mod frontend; +pub mod options; +pub mod printer; +pub mod settings; +pub mod util; + +#[allow(unused_imports)] +pub use bindings::*; diff --git a/cpdb-sys/src/options.rs b/cpdb-sys/src/options.rs new file mode 100644 index 0000000..bd80713 --- /dev/null +++ b/cpdb-sys/src/options.rs @@ -0,0 +1,112 @@ +//! Printer options (capabilities) from C `cpdb_options_t`. + +use crate::bindings as ffi; +use crate::error::{CpdbError, Result}; +use crate::util; +use glib_sys::{GHashTableIter, g_hash_table_iter_init, g_hash_table_iter_next}; +use std::mem::MaybeUninit; + +/// A single printer option with its supported choices. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OptionInfo { + /// The option name, e.g. `"copies"` or `"sides"`. + pub name: String, + /// The default value as reported by the backend. + pub default_value: String, + /// The option group (e.g. `"General"`), or an empty string when unset. + pub group: String, + /// All values the printer supports for this option. + pub supported_values: Vec, +} + +/// An owned snapshot of every option in a `cpdb_options_t`. +#[derive(Debug, Clone, Default)] +pub struct OptionsCollection { + /// Every option discovered. + pub options: Vec, +} + +impl OptionsCollection { + /// Builds an [`OptionsCollection`] by iterating `raw.table`. + /// + /// # Safety + /// + /// `raw` must be either null or a valid pointer to a fully initialised + /// `cpdb_options_t` whose `table` field is a valid `GHashTable*`. + pub unsafe fn from_raw(raw: *mut ffi::cpdb_options_t) -> Result { + if raw.is_null() { + return Err(CpdbError::NullPointer); + } + + let table = unsafe { (*raw).table }; + + if table.is_null() { + return Ok(Self::default()); + } + + let mut options: Vec = Vec::new(); + + unsafe { + let mut iter = MaybeUninit::::uninit(); + g_hash_table_iter_init(iter.as_mut_ptr(), table as *mut glib_sys::GHashTable); + let mut iter = iter.assume_init(); + + let mut key: *mut libc::c_void = std::ptr::null_mut(); + let mut value: *mut libc::c_void = std::ptr::null_mut(); + + while g_hash_table_iter_next(&mut iter, &mut key, &mut value) != 0 { + if value.is_null() { + continue; + } + let opt = value as *mut ffi::cpdb_option_t; + + let name = util::cstr_to_string((*opt).option_name).unwrap_or_default(); + let default_value = util::cstr_to_string((*opt).default_value).unwrap_or_default(); + let group = util::cstr_to_string((*opt).group_name).unwrap_or_default(); + + let mut supported_values: Vec = + Vec::with_capacity((*opt).num_supported as usize); + + if !(*opt).supported_values.is_null() && (*opt).num_supported > 0 { + for i in 0..((*opt).num_supported as usize) { + let s_ptr = *(*opt).supported_values.add(i); + if !s_ptr.is_null() { + if let Ok(s) = util::cstr_to_string(s_ptr) { + supported_values.push(s); + } + } + } + } + + options.push(OptionInfo { + name, + default_value, + group, + supported_values, + }); + } + } + + Ok(Self { options }) + } + + /// Returns the number of options in this collection. + pub fn len(&self) -> usize { + self.options.len() + } + + /// Returns `true` if this collection has no options. + pub fn is_empty(&self) -> bool { + self.options.is_empty() + } + + /// Finds an option by name (linear search). + pub fn get(&self, name: &str) -> Option<&OptionInfo> { + self.options.iter().find(|o| o.name == name) + } + + /// Returns an iterator over all options. + pub fn iter(&self) -> impl Iterator { + self.options.iter() + } +} diff --git a/src/printer.rs b/cpdb-sys/src/printer.rs similarity index 99% rename from src/printer.rs rename to cpdb-sys/src/printer.rs index 8aa4030..e4ec67e 100644 --- a/src/printer.rs +++ b/cpdb-sys/src/printer.rs @@ -20,12 +20,12 @@ //! lock internally. If you need to dispatch printer operations from //! multiple threads, wrap a single printer in a [`std::sync::Mutex`]. -use crate::callbacks::{self, AcquireCompletion}; +use super::bindings as ffi; +use super::callbacks::{self, AcquireCompletion}; +use super::frontend::Frontend; +use super::util; use crate::error::{CpdbError, Result}; -use crate::ffi; -use crate::frontend::Frontend; use crate::options::OptionsCollection; -use crate::util; use libc::c_char; use std::collections::HashMap; use std::ffi::{CStr, CString}; @@ -231,10 +231,10 @@ impl<'frontend> Printer<'frontend> { /// The returned string is owned by Rust; the cpdb-libs allocation is /// freed inside this call. pub fn get_updated_state(&self) -> Result { - // SAFETY: cpdbGetState returns a freshly `g_strdup`'d string we own. + // SAFETY: cpdbGetState returns a borrowed string owned by the printer object. unsafe { let raw = ffi::cpdbGetState(self.raw.as_ptr()); - util::cstr_to_string_and_g_free(raw) + util::cstr_to_string(raw) } } @@ -482,7 +482,7 @@ impl<'frontend> Printer<'frontend> { "cpdbGetAllOptions returned null — call acquire_details() first".into(), ) })?; - Ok(OptionsCollection::from_raw(opts)) + OptionsCollection::from_raw(opts.as_ptr()) } } diff --git a/src/settings.rs b/cpdb-sys/src/settings.rs similarity index 99% rename from src/settings.rs rename to cpdb-sys/src/settings.rs index 767705b..30694e5 100644 --- a/src/settings.rs +++ b/cpdb-sys/src/settings.rs @@ -1,7 +1,7 @@ //! Safe wrappers around `cpdb_settings_t`, `cpdb_options_t`, and `cpdb_media_t`. +use super::bindings as ffi; use crate::error::{CpdbError, Result}; -use crate::ffi; use std::ffi::CString; use std::ptr::NonNull; diff --git a/src/util.rs b/cpdb-sys/src/util.rs similarity index 99% rename from src/util.rs rename to cpdb-sys/src/util.rs index b5bbf2b..0fab6a0 100644 --- a/src/util.rs +++ b/cpdb-sys/src/util.rs @@ -1,7 +1,7 @@ //! Small FFI utilities shared by the higher-level modules. +use super::bindings as ffi; use crate::error::{CpdbError, Result}; -use crate::ffi; use libc::c_char; use std::ffi::{CStr, CString}; diff --git a/examples/basic_usage.rs b/examples/ffi_basic_usage.rs similarity index 100% rename from examples/basic_usage.rs rename to examples/ffi_basic_usage.rs diff --git a/examples/cli_printer_manager.rs b/examples/ffi_cli_printer_manager.rs similarity index 100% rename from examples/cli_printer_manager.rs rename to examples/ffi_cli_printer_manager.rs diff --git a/examples/filter_printers.rs b/examples/filter_printers.rs new file mode 100644 index 0000000..4c7bd28 --- /dev/null +++ b/examples/filter_printers.rs @@ -0,0 +1,51 @@ +//! Example: Filtering remote/temporary printers. + +use cpdb_rs::{CpdbClient, DiscoveryEvent}; +use futures_util::StreamExt; +use tokio::time::{Duration, sleep}; + +#[tokio::main] +async fn main() -> cpdb_rs::Result<()> { + let client = CpdbClient::new().await?; + println!("Connected to {} backend(s).", client.backend_count()); + + let stream_client = client.clone(); + tokio::spawn(async move { + let mut stream = stream_client.discovery_stream().await.unwrap(); + while let Some(event) = stream.next().await { + match event { + DiscoveryEvent::PrinterAdded(p) => { + println!(" [SIGNAL] + ADDED: {} ({})", p.name, p.backend); + } + DiscoveryEvent::PrinterRemoved { id, backend } => { + println!(" [SIGNAL] - REMOVED: {} ({})", id, backend); + } + DiscoveryEvent::PrinterStateChanged { id, state, .. } => { + println!(" [SIGNAL] ~ STATE: {} -> {}", id, state); + } + } + } + }); + + // Wait for initial `doListing` signals to settle + sleep(Duration::from_millis(500)).await; + + println!("\n--- Hiding remote printers ---"); + client.show_remote_printers(false).await; + sleep(Duration::from_millis(1500)).await; + + println!("\n--- Restoring remote printers ---"); + client.show_remote_printers(true).await; + sleep(Duration::from_millis(1500)).await; + + println!("\n--- Hiding temporary printers ---"); + client.show_temporary_printers(false).await; + sleep(Duration::from_millis(1500)).await; + + println!("\n--- Restoring temporary printers ---"); + client.show_temporary_printers(true).await; + sleep(Duration::from_millis(1500)).await; + + println!("\nDone filtering."); + Ok(()) +} diff --git a/examples/get_translations.rs b/examples/get_translations.rs new file mode 100644 index 0000000..401a4f3 --- /dev/null +++ b/examples/get_translations.rs @@ -0,0 +1,60 @@ +//! Example: Fetch localized translations for a printer's options. + +use cpdb_rs::CpdbClient; + +#[tokio::main] +async fn main() -> cpdb_rs::Result<()> { + let client = CpdbClient::new().await?; + + let printers = client.get_all_printers().await?; + let printer = match printers.iter().find(|p| p.is_ready()) { + Some(p) => p, + None => { + eprintln!("No ready printers found."); + return Ok(()); + } + }; + + println!("Printer: {} [{}]\n", printer.name, printer.id); + + // Fetch options first (so we know what to translate) + let (options, _media) = client + .get_printer_details(&printer.id, &printer.backend) + .await?; + println!("Options ({} total):", options.len()); + for opt in options.iter().take(5) { + println!(" {}: default='{}'", opt.name, opt.default_value); + } + + // Fetch translations + // Try the system locale, falling back to en_US + let locale = std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()); + // CPDB expects just the language_COUNTRY part (e.g. "en_US") + let locale_short = locale.split('.').next().unwrap_or("en_US"); + + println!("\n=== Translations (locale: {}) ===", locale_short); + match client + .get_translations(&printer.id, &printer.backend, locale_short) + .await + { + Ok(translations) => { + if translations.is_empty() { + println!(" (no translations returned - backend may not support this locale)"); + } else { + // Sort for consistent output + let mut entries: Vec<_> = translations.iter().collect(); + entries.sort_by_key(|(k, _)| (*k).clone()); + + println!(" {} entries:", entries.len()); + for (key, label) in &entries { + println!(" {} -> {}", key, label); + } + } + } + Err(e) => { + eprintln!(" Error: {e}"); + } + } + + Ok(()) +} diff --git a/examples/print_a_document.rs b/examples/print_a_document.rs new file mode 100644 index 0000000..62c457e --- /dev/null +++ b/examples/print_a_document.rs @@ -0,0 +1,98 @@ +//! Example: Submit a print job via print_fd() with fallback to print_socket(). +//! +//! Targets `dummy_printer` by default. Change PRINTER_ID for a different target. + +use cpdb_rs::{CpdbClient, CpdbError}; +use tokio::io::AsyncWriteExt; +use tokio::net::UnixStream; + +/// Change this to target a different printer. +const PRINTER_ID: &str = "dummy_printer"; +const BACKEND: &str = "CUPS"; + +#[tokio::main] +async fn main() -> cpdb_rs::Result<()> { + let client = CpdbClient::new().await?; + + let printers = client.get_all_printers().await?; + let target = printers.iter().find(|p| p.id == PRINTER_ID); + match target { + Some(p) => println!( + "Target: {} [state={}, accepting={}]", + p.name, p.state, p.accepting_jobs + ), + None => { + eprintln!("Printer '{}' not found. Available:", PRINTER_ID); + for p in &printers { + eprintln!(" {} [{}]", p.name, p.id); + } + return Ok(()); + } + } + + let settings = [("copies", "1"), ("media", "iso_a4_210x297mm")]; + let title = "cpdb-rs test page"; + + println!("\nSubmitting job to '{}'...", PRINTER_ID); + + let postscript = b"%!PS-Adobe-3.0 +%%Title: cpdb-rs test page +%%Pages: 1 +%%EndComments + +%%Page: 1 1 +/Helvetica findfont 24 scalefont setfont +72 700 moveto +(Hello from cpdb-rs!) show +showpage +%%EOF +"; + + match client.print_fd(PRINTER_ID, BACKEND, &settings, title).await { + Ok((job_id, fd)) => { + println!("printFd SUCCESS: Job ID: {}", job_id); + + // Convert the zbus OwnedFd into a std OwnedFd, then into a File to prevent double-drop + let std_fd: std::os::fd::OwnedFd = fd.into(); + let mut file = std::fs::File::from(std_fd); + use std::io::Write; + file.write_all(postscript).expect("Failed to write to FD"); + drop(file); // Signals EOF + + println!("Document written to FD and stream closed."); + } + Err(CpdbError::DbusError(zbus::Error::MethodError(name, _, _))) + if name.as_str() == "org.freedesktop.DBus.Error.UnknownMethod" => + { + println!("printFd not supported by backend. Falling back to printSocket..."); + + let (job_id, socket_path) = client + .print_socket(PRINTER_ID, BACKEND, &settings, title) + .await?; + + println!( + "printSocket SUCCESS: Job ID: {}, Socket: {}", + job_id, socket_path + ); + + let mut stream = UnixStream::connect(&socket_path) + .await + .expect("Failed to connect to print socket"); + + stream + .write_all(postscript) + .await + .expect("Failed to write to socket"); + + stream.shutdown().await.unwrap(); // Signals EOF + println!("Document written to socket and stream closed."); + } + Err(e) => { + return Err(e); + } + } + + println!("\nCheck the job with: lpstat -o"); + + Ok(()) +} diff --git a/examples/zbus_test.rs b/examples/zbus_test.rs new file mode 100644 index 0000000..6bb0597 --- /dev/null +++ b/examples/zbus_test.rs @@ -0,0 +1,102 @@ +use cpdb_rs::CpdbClient; +use cpdb_rs::events::DiscoveryEvent; +use futures_util::StreamExt; + +/// This example shows how to use the CpdbClient to discover printers and print their details. +/// It also shows how to listen for live discovery events. +#[tokio::main] +async fn main() -> cpdb_rs::Result<()> { + let client = CpdbClient::new().await?; + println!("Connected to {} backend(s).\n", client.backend_count()); + + // Initial population via GetAllPrinters + let printers = client.get_all_printers().await?; + println!("=== All Printers ({} found) ===", printers.len()); + for p in &printers { + println!( + " {} [{}] - {} (state={}, accepting={})", + p.name, p.id, p.make_model, p.state, p.accepting_jobs + ); + } + + // Default printer + match client.get_default_printer("CUPS").await { + Ok(default) => { + let display = if default.is_empty() || default == "[Invalid UTF-8]" || default == "NA" { + "Not Set" + } else { + &default + }; + println!("\nDefault CUPS printer: {display}"); + } + Err(_) => println!("\nDefault CUPS printer: Not Set"), + } + + // Fetch details for the first accepting printer + if let Some(p) = printers.iter().find(|p| p.is_ready()) { + println!("\n=== Details for '{}' ===", p.name); + match client.get_printer_details(&p.id, &p.backend).await { + Ok((options, media)) => { + println!("Options ({} total):", options.len()); + for opt in options.iter().take(8) { + println!( + " {}: default='{}', choices=[{}]", + opt.name, + opt.default_value, + opt.supported_values.join(", ") + ); + } + println!("Media ({} total):", media.len()); + for m in media.iter().take(5) { + println!( + " {}: {}×{} mm, {} margin(s)", + m.name, + m.width as f64 / 100.0, + m.length as f64 / 100.0, + m.margins.len() + ); + } + } + Err(e) => eprintln!(" Failed to get details: {e}"), + } + } else { + println!("\nNo ready printers to query details for."); + } + + // Live discovery stream + println!("\n=== Listening for live discovery events ==="); + println!( + "(Try: sudo lpadmin -p TestPrinter -E -v ipp://localhost/printers/test -m everywhere)" + ); + println!("(Press Ctrl+C to stop)\n"); + + // Spawn a background task to keep backends alive + let keep_alive_client = client.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(15)); + loop { + interval.tick().await; + keep_alive_client.keep_alive_all().await; + } + }); + + let mut stream = client.discovery_stream().await?; + while let Some(event) = stream.next().await { + match &event { + DiscoveryEvent::PrinterAdded(snap) => { + println!( + "+ ADDED: {} [{}] backend={}", + snap.name, snap.id, snap.backend + ); + } + DiscoveryEvent::PrinterRemoved { id, backend } => { + println!("- REMOVED: id={id}, backend={backend}"); + } + DiscoveryEvent::PrinterStateChanged { id, state, .. } => { + println!("~ STATE: id={id}, state={state}"); + } + } + } + + Ok(()) +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..d014c80 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,524 @@ +//! Async CPDB client. +//! +//! [`CpdbClient`] discovers all CPDB backends on the D-Bus session bus +//! and provides methods for printer enumeration, capability querying, +//! and job submission. +//! +//! # Example +//! +//! ```rust,no_run +//! use cpdb_rs::CpdbClient; +//! +//! # async fn example() -> cpdb_rs::Result<()> { +//! let client = CpdbClient::new().await?; +//! let printers = client.get_all_printers().await?; +//! for p in &printers { +//! println!("{} [{}]", p.name, p.id); +//! } +//! # Ok(()) +//! # } +//! ``` + +use std::collections::HashMap; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; + +use futures_util::{Stream, StreamExt, stream::SelectAll}; +use zbus::zvariant::OwnedFd; + +use crate::error::{CpdbError, Result}; +use crate::events::{DiscoveryEvent, PrinterSnapshot}; +use crate::media::MediaCollection; +use crate::options::OptionsCollection; +use crate::proxy::PrintBackendProxy; + +const AUTO_EXIT_TIMEOUT: Duration = Duration::from_secs(30); +const RETRY_INTERVAL_MS: Duration = Duration::from_millis(200); + +macro_rules! retry_dbus { + ($proxy:expr, $method:ident($($arg:expr),*)) => {{ + let __proxy = &($proxy); + let mut __result = __proxy.$method($($arg),*).await; + if let Err(zbus::Error::MethodError(ref __n, _, _)) = __result { + if __n.as_str() == "org.freedesktop.DBus.Error.UnknownMethod" { + tokio::time::sleep(RETRY_INTERVAL_MS).await; + __result = __proxy.$method($($arg),*).await; + } + } + __result + }}; +} + +/// A connected CPDB client managing proxies to all discovered print backends. +/// +/// Created via [`CpdbClient::new()`]. The client is [`Clone`]-able - cloning +/// shares the underlying D-Bus connection. +/// +/// # Usage +/// +/// ```rust,no_run +/// # use cpdb_rs::CpdbClient; +/// # async fn example() -> cpdb_rs::Result<()> { +/// let client = CpdbClient::new().await?; +/// +/// // Initial population +/// let printers = client.get_all_printers().await?; +/// +/// // Fetch capabilities when the user selects a printer +/// let (options, media) = client.get_printer_details(&printers[0].id, "CUPS").await?; +/// +/// // Submit a print job +/// let settings = [("copies", "1"), ("media", "iso_a4_210x297mm")]; +/// let (job_id, fd) = client.print_fd(&printers[0].id, "CUPS", &settings, "My Doc").await?; +/// # Ok(()) } +/// ``` +#[derive(Clone)] +pub struct CpdbClient { + backends: Vec, +} + +/// Internal handle for a single backend (e.g. CUPS). +#[derive(Clone)] +struct BackendHandle { + /// Full D-Bus service name, e.g. `"org.openprinting.Backend.CUPS"`. + service_name: String, + /// The zbus-generated proxy for this backend's PrintBackend interface. + proxy: PrintBackendProxy<'static>, +} + +impl CpdbClient { + /// Connect to the D-Bus session bus and discover all CPDB backends. + /// + /// 1. Opens a session bus connection. + /// 2. Calls `ListActivatableNames` to find `org.openprinting.Backend.*` services. + /// 3. Creates a [`PrintBackendProxy`] for each discovered backend. + /// + /// Backends that fail to connect are logged and skipped. + /// + /// # Errors + /// + /// Returns [`CpdbError::DbusError`] if the session bus itself is unavailable. + pub async fn new() -> Result { + let connection = zbus::Connection::session().await.map_err(CpdbError::from)?; + + let dbus = zbus::fdo::DBusProxy::new(&connection) + .await + .map_err(CpdbError::from)?; + let names = dbus + .list_activatable_names() + .await + .map_err(CpdbError::from)?; + + let backend_names: Vec = names + .iter() + .filter(|n| n.starts_with("org.openprinting.Backend.")) + .map(|n| n.to_string()) + .collect(); + + let mut backends = Vec::new(); + for name in &backend_names { + let bus_name = match zbus::names::BusName::try_from(name.clone()) { + Ok(n) => n, + Err(_) => continue, + }; + match PrintBackendProxy::builder(&connection) + .destination(bus_name)? + .path("/")? + .build() + .await + { + Ok(proxy) => { + backends.push(BackendHandle { + service_name: name.clone(), + proxy, + }); + } + Err(e) => { + log::warn!("cpdb-rs: skipping backend {}: {}", name, e); + } + } + } + + Ok(Self { backends }) + } + + /// Returns the number of connected backends. + pub fn backend_count(&self) -> usize { + self.backends.len() + } + + /// Fetches all known printers from all connected backends. + /// + /// This is the **initial population** method - equivalent to what the C + /// library's `fetchPrinterListFromBackend()` does. It calls `GetAllPrinters` + /// on each backend and unpacks the variant-wrapped printer data into + /// [`PrinterSnapshot`]s. + /// + /// Use this to populate the printer list. + /// Use [`discovery_stream()`](Self::discovery_stream) for live updates after that. + /// + /// # Errors + /// + /// Returns errors if a D-Bus call fails. Backends that fail individually + /// are skipped, and printers from working backends are still returned. + pub async fn get_all_printers(&self) -> Result> { + let mut printers = Vec::new(); + + for bh in &self.backends { + let result = retry_dbus!(bh.proxy, get_all_printers()); + + let (_count, raw_printers) = match result { + Ok(v) => v, + Err(e) => { + log::error!( + "cpdb-rs: error fetching printers from {}: {}", + bh.service_name, + e + ); + continue; + } + }; + + for raw in raw_printers { + printers.push(PrinterSnapshot { + id: raw.0, + name: raw.1, + info: raw.2, + location: raw.3, + make_model: raw.4, + accepting_jobs: raw.5, + state: raw.6, + backend: raw.7, + }); + } + } + + Ok(printers) + } + + /// Like [`get_all_printers()`](Self::get_all_printers) but returns only + /// printers matching the current filter state. + /// + /// Call [`show_remote_printers(false)`](Self::show_remote_printers) or + /// [`show_temporary_printers(false)`](Self::show_temporary_printers) + /// first to set the filter, then call this to get the filtered list. + pub async fn get_filtered_printers(&self) -> Result> { + let mut printers = Vec::new(); + + for bh in &self.backends { + let result = retry_dbus!(bh.proxy, get_filtered_printer_list()); + + let (_count, raw_printers) = match result { + Ok(v) => v, + Err(e) => { + log::error!( + "cpdb-rs: error fetching filtered printers from {}: {}", + bh.service_name, + e + ); + continue; + } + }; + + for raw in raw_printers { + printers.push(PrinterSnapshot { + id: raw.0, + name: raw.1, + info: raw.2, + location: raw.3, + make_model: raw.4, + accepting_jobs: raw.5, + state: raw.6, + backend: raw.7, + }); + } + } + + Ok(printers) + } + + /// Returns a merged stream of [`DiscoveryEvent`]s from all backends. + /// + /// The stream emits events as printers are added, removed, or change + /// state. After subscribing to signals, it calls `doListing(true)` on + /// each backend to trigger initial `PrinterAdded` emissions. + /// + /// A background task is automatically spawned to ping the backends + /// periodically, preventing them from timing out. When this stream is + /// dropped, the keep-alive task is cancelled. + /// + /// # Errors + /// + /// Returns [`CpdbError::DbusError`] if subscribing to D-Bus signals fails. + pub async fn discovery_stream(&self) -> Result { + let mut all: SelectAll> = + SelectAll::new(); + + for bh in &self.backends { + // Subscribe to PrinterAdded signals + let added = bh + .proxy + .receive_printer_added() + .await + .map_err(CpdbError::from)?; + all.push( + added + .filter_map(|sig| async move { + let a = sig.args().ok()?; + Some(DiscoveryEvent::PrinterAdded(PrinterSnapshot { + id: a.printer_id.to_string(), + name: a.printer_name.to_string(), + info: a.printer_info.to_string(), + location: a.printer_location.to_string(), + make_model: a.printer_make_and_model.to_string(), + accepting_jobs: a.printer_is_accepting_jobs, + state: a.printer_state.to_string(), + backend: a.backend_name.to_string(), + })) + }) + .boxed(), + ); + + // Subscribe to PrinterRemoved signals + let removed = bh + .proxy + .receive_printer_removed() + .await + .map_err(CpdbError::from)?; + all.push( + removed + .filter_map(|sig| async move { + let a = sig.args().ok()?; + Some(DiscoveryEvent::PrinterRemoved { + id: a.printer_id.to_string(), + backend: a.backend_name.to_string(), + }) + }) + .boxed(), + ); + + // Subscribe to PrinterStateChanged signals + let changed = bh + .proxy + .receive_printer_state_changed() + .await + .map_err(CpdbError::from)?; + all.push( + changed + .filter_map(|sig| async move { + let a = sig.args().ok()?; + Some(DiscoveryEvent::PrinterStateChanged { + id: a.printer_id.to_string(), + backend: a.backend_name.to_string(), + state: a.printer_state.to_string(), + accepting_jobs: a.printer_is_accepting_jobs, + }) + }) + .boxed(), + ); + } + + for bh in &self.backends { + let _ = bh.proxy.do_listing(true).await; + } + + let client = self.clone(); + let keep_alive_task = tokio::spawn(async move { + let ping_interval = std::time::Duration::from_secs(AUTO_EXIT_TIMEOUT.as_secs() / 2); + loop { + tokio::time::sleep(ping_interval).await; + client.keep_alive_all().await; + } + }); + + Ok(DiscoveryStream { + inner: all, + keep_alive_task, + }) + } + + /// Keeps all backends alive to prevent them from auto-exiting. + /// + /// CPDB backends automatically exit after a period of inactivity (typically 30 seconds). + /// If you are listening to a `discovery_stream()`, you should call this method + /// periodically (e.g. every 10 seconds) to ensure the backends stay running + /// and continue emitting discovery signals. + pub async fn keep_alive_all(&self) { + for bh in &self.backends { + let _ = bh.proxy.keep_alive().await; + } + } + + /// Fetches all options and media for a printer in a single D-Bus call. + /// + /// This calls the backend's `GetAllOptions` method, which returns both + /// the printer's capabilities (duplex, color mode, etc.) and its + /// supported paper sizes with margin information. + /// + /// # Arguments + /// + /// * `printer_id` - The printer's unique ID (from [`PrinterSnapshot::id`]). + /// * `backend` - The backend name, e.g. `"CUPS"` or the full service + /// name `"org.openprinting.Backend.CUPS"`. + /// + /// # Errors + /// + /// * [`CpdbError::BackendError`] if no backend matches `backend`. + /// * [`CpdbError::DbusError`] if the D-Bus call fails. + pub async fn get_printer_details( + &self, + printer_id: &str, + backend: &str, + ) -> Result<(OptionsCollection, MediaCollection)> { + let proxy = self.proxy_for(backend)?; + let (_n_opts, raw_opts, _n_media, raw_media) = + retry_dbus!(proxy, get_all_options(printer_id)).map_err(CpdbError::from)?; + Ok(( + OptionsCollection::from_dbus(raw_opts), + MediaCollection::from_dbus(raw_media), + )) + } + + /// Fetches localized labels for a printer's options and choices. + /// + /// Returns a map of internal name -> human-readable label, e.g. + /// `{"sides" -> "Two-Sided", "one-sided" -> "Off", ...}`. + /// + /// # Arguments + /// + /// * `printer_id` - The printer's unique ID. + /// * `backend` - The backend name. + /// * `locale` - A POSIX locale string, e.g. `"en_US"` or `"de_DE"`. + pub async fn get_translations( + &self, + printer_id: &str, + backend: &str, + locale: &str, + ) -> Result> { + let proxy = self.proxy_for(backend)?; + retry_dbus!(proxy, get_all_translations(printer_id, locale)).map_err(CpdbError::from) + } + + /// Returns the default printer ID for a specific backend. + pub async fn get_default_printer(&self, backend: &str) -> Result { + let proxy = self.proxy_for(backend)?; + retry_dbus!(proxy, get_default_printer()).map_err(CpdbError::from) + } + + /// Submits a print job and returns a writable file descriptor. + /// + /// The backend creates a CUPS job and returns the write end of a + /// socketpair. The caller writes the document data into `fd` and + /// closes it when done - the backend reads from the other end and + /// forwards it to the print system. + /// + /// # Arguments + /// + /// * `printer_id` - The printer's unique ID. + /// * `backend` - The backend name. + /// * `settings` - Print settings as key-value pairs, e.g. + /// `[("copies", "2"), ("media", "iso_a4_210x297mm")]`. + /// * `title` - The job title shown in the print queue. + /// + /// # Returns + /// + /// A tuple of `(job_id, fd)` where `job_id` is the CUPS job ID + /// string and `fd` is the writable end of the socketpair. + pub async fn print_fd( + &self, + printer_id: &str, + backend: &str, + settings: &[(&str, &str)], + title: &str, + ) -> Result<(String, OwnedFd)> { + let proxy = self.proxy_for(backend)?; + let (job_id, fd) = retry_dbus!( + proxy, + print_fd(printer_id, settings.len() as i32, settings, title) + ) + .map_err(CpdbError::from)?; + Ok((job_id, fd)) + } + + /// Submits a print job and returns a Unix domain socket path to write the document to. + /// + /// The caller must connect to the returned socket path and write the document + /// data, closing the stream when finished. + pub async fn print_socket( + &self, + printer_id: &str, + backend: &str, + settings: &[(&str, &str)], + title: &str, + ) -> Result<(String, String)> { + let proxy = self.proxy_for(backend)?; + let (job_id, socket_path) = retry_dbus!( + proxy, + print_socket(printer_id, settings.len() as i32, settings, title) + ) + .map_err(CpdbError::from)?; + Ok((job_id, socket_path)) + } + + /// Sets the visibility of remote printers on all connected backends. + /// + /// When `visible` is `false`, printers discovered via DNS-SD / mDNS + /// on remote hosts are hidden from discovery signals. + pub async fn show_remote_printers(&self, visible: bool) { + for b in &self.backends { + let _ = b.proxy.show_remote_printers(visible).await; + } + } + + /// Sets the visibility of temporary (auto-discovered) printers on + /// all connected backends. + pub async fn show_temporary_printers(&self, visible: bool) { + for b in &self.backends { + let _ = b.proxy.show_temporary_printers(visible).await; + } + } + + /// Finds the proxy for a backend by name. + /// + /// Accepts either a short name (`"CUPS"`) or a full D-Bus service + /// name (`"org.openprinting.Backend.CUPS"`). + fn proxy_for(&self, backend: &str) -> Result<&PrintBackendProxy<'static>> { + self.backends + .iter() + .find(|b| { + if b.service_name == backend { + return true; + } + if let Some(idx) = b.service_name.rfind('.') { + &b.service_name[idx + 1..] == backend + } else { + false + } + }) + .map(|b| &b.proxy) + .ok_or_else(|| { + CpdbError::BackendError(format!("No backend found matching '{}'", backend)) + }) + } +} + +/// A stream of live discovery events that keeps connected backends alive. +pub struct DiscoveryStream { + inner: SelectAll>, + keep_alive_task: tokio::task::JoinHandle<()>, +} + +impl Stream for DiscoveryStream { + type Item = DiscoveryEvent; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_next(cx) + } +} + +impl Drop for DiscoveryStream { + fn drop(&mut self) { + self.keep_alive_task.abort(); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..5ccdad4 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,97 @@ +//! Printer configuration persistence types. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Saved user preferences. Serializable, no I/O. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct PrinterConfig { + /// Last-used printer identifier + pub last_printer_id: Option, + /// Last-used backend name + pub last_backend: Option, + /// Last-used settings (copies, media, sides, etc.) + pub settings: HashMap, +} + +impl PrinterConfig { + /// Creates a new config capturing the current dialog state. + pub fn new(printer_id: &str, backend: &str, settings: HashMap) -> Self { + Self { + last_printer_id: Some(printer_id.to_string()), + last_backend: Some(backend.to_string()), + settings, + } + } + + /// Returns `true` if this config has a saved printer. + pub fn has_printer(&self) -> bool { + self.last_printer_id.is_some() + } + + /// Gets a saved setting value by key. + pub fn get_setting(&self, key: &str) -> Option<&str> { + self.settings.get(key).map(|s| s.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_is_empty() { + let config = PrinterConfig::default(); + assert!(config.last_printer_id.is_none()); + assert!(config.last_backend.is_none()); + assert!(config.settings.is_empty()); + assert!(!config.has_printer()); + } + + #[test] + fn new_config_captures_state() { + let mut settings = HashMap::new(); + settings.insert("copies".to_string(), "2".to_string()); + settings.insert("media".to_string(), "iso_a4_210x297mm".to_string()); + + let config = PrinterConfig::new("HP-123", "CUPS", settings); + assert_eq!(config.last_printer_id.as_deref(), Some("HP-123")); + assert_eq!(config.last_backend.as_deref(), Some("CUPS")); + assert!(config.has_printer()); + assert_eq!(config.get_setting("copies"), Some("2")); + assert_eq!(config.get_setting("media"), Some("iso_a4_210x297mm")); + } + + #[test] + fn get_setting_returns_none_for_missing() { + let config = PrinterConfig::default(); + assert!(config.get_setting("nonexistent").is_none()); + } + + #[test] + fn serde_roundtrip_json() { + let mut settings = HashMap::new(); + settings.insert("copies".to_string(), "3".to_string()); + settings.insert("sides".to_string(), "two-sided-long-edge".to_string()); + + let config = PrinterConfig::new("Epson-ET-2850", "CUPS", settings); + let json = serde_json::to_string(&config).unwrap(); + let loaded: PrinterConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(config, loaded); + } + + #[test] + fn serde_roundtrip_default() { + let config = PrinterConfig::default(); + let json = serde_json::to_string(&config).unwrap(); + let loaded: PrinterConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(config, loaded); + } + + #[test] + fn config_is_clone() { + let config = PrinterConfig::new("test", "CUPS", HashMap::new()); + let clone = config.clone(); + assert_eq!(config, clone); + } +} diff --git a/src/error.rs b/src/error.rs index 2599477..6dfea02 100644 --- a/src/error.rs +++ b/src/error.rs @@ -54,7 +54,51 @@ pub enum CpdbError { /// An I/O error bubbled up from std::io. #[error("IO error: {0}")] IoError(#[from] std::io::Error), + + /// An unexpected status code was returned. + #[error("Invalid status code: {0}")] + InvalidStatus(i32), + + /// The requested operation is not supported. + #[error("Unsupported operation")] + Unsupported, + + /// A D-Bus protocol error occurred. + #[cfg(feature = "zbus-backend")] + #[error("D-Bus error: {0}")] + DbusError(#[from] zbus::Error), + + /// A D-Bus FDO standard error occurred. + #[cfg(feature = "zbus-backend")] + #[error("D-Bus FDO error: {0}")] + FdoError(#[from] zbus::fdo::Error), } /// Shorthand `Result` alias used throughout the crate. pub type Result = std::result::Result; + +/// Convert a `cpdb_sys` FFI error into the crate-wide `CpdbError`. +/// +/// This allows `?` to work across the FFI boundary when calling functions +/// from `cpdb_sys` modules (e.g. `util`, `printer`) inside `cpdb_rs` code. +#[cfg(feature = "ffi")] +impl From for CpdbError { + fn from(e: cpdb_sys::error::CpdbError) -> Self { + match e { + cpdb_sys::error::CpdbError::NullPointer => Self::NullPointer, + cpdb_sys::error::CpdbError::InvalidPrinter => Self::InvalidPrinter, + cpdb_sys::error::CpdbError::NotFound(s) => Self::NotFound(s), + cpdb_sys::error::CpdbError::PrinterError(s) => Self::PrinterError(s), + cpdb_sys::error::CpdbError::JobFailed(s) => Self::JobFailed(s), + cpdb_sys::error::CpdbError::BackendError(s) => Self::BackendError(s), + cpdb_sys::error::CpdbError::FrontendError(s) => Self::FrontendError(s), + cpdb_sys::error::CpdbError::OptionError(s) => Self::OptionError(s), + cpdb_sys::error::CpdbError::Utf8Error(e) => Self::Utf8Error(e), + cpdb_sys::error::CpdbError::NulError(e) => Self::NulError(e), + cpdb_sys::error::CpdbError::IoError(e) => Self::IoError(e), + cpdb_sys::error::CpdbError::InvalidStatus(c) => Self::InvalidStatus(c), + cpdb_sys::error::CpdbError::Unsupported => Self::Unsupported, + _ => Self::FrontendError(format!("cpdb-sys error: {e}")), + } + } +} diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..4c9a34a --- /dev/null +++ b/src/events.rs @@ -0,0 +1,152 @@ +//! Discovery events emitted by CPDB backends. +//! +//! These map directly to the D-Bus signals defined in the +//! `org.openprinting.PrintBackend` interface. + +/// An event emitted during printer discovery or state monitoring. +#[derive(Debug, Clone)] +pub enum DiscoveryEvent { + /// A printer was discovered or re-announced. + PrinterAdded(PrinterSnapshot), + /// A printer was removed from the backend. + PrinterRemoved { + /// The printer's unique ID. + id: String, + /// The backend that reported the removal. + backend: String, + }, + /// A printer's state or accepting-jobs status changed. + PrinterStateChanged { + /// The printer's unique ID. + id: String, + /// The backend that reported the change. + backend: String, + /// The new state string. + state: String, + /// Whether the printer is accepting new jobs. + accepting_jobs: bool, + }, +} + +/// Snapshot of a printer's identity and status at a point in time. +#[derive(Debug, Clone)] +pub struct PrinterSnapshot { + /// The backend-assigned unique printer ID. + pub id: String, + /// The human-readable display name. + pub name: String, + /// A free-form description of the printer. + pub info: String, + /// Physical location string as reported by the backend. + pub location: String, + /// Make and model string (e.g. `"HP LaserJet Pro"`). + pub make_model: String, + /// Current state string (e.g. `"idle"`, `"processing"`, `"stopped"`). + pub state: String, + /// Whether the printer is currently accepting new jobs. + pub accepting_jobs: bool, + /// The backend that owns this printer (e.g. `"CUPS"`). + pub backend: String, +} + +impl PrinterSnapshot { + /// Returns `true` when the printer is idle and accepting jobs. + pub fn is_ready(&self) -> bool { + self.state == "idle" && self.accepting_jobs + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_snapshot() -> PrinterSnapshot { + PrinterSnapshot { + id: "HP-LaserJet-Pro".to_string(), + name: "HP LaserJet Pro".to_string(), + info: "Office printer".to_string(), + location: "Room 42".to_string(), + make_model: "HP LaserJet Pro MFP".to_string(), + state: "idle".to_string(), + accepting_jobs: true, + backend: "CUPS".to_string(), + } + } + + #[test] + fn snapshot_is_ready_when_idle_and_accepting() { + let snap = sample_snapshot(); + assert!(snap.is_ready()); + } + + #[test] + fn snapshot_not_ready_when_busy() { + let mut snap = sample_snapshot(); + snap.state = "printing".to_string(); + assert!(!snap.is_ready()); + } + + #[test] + fn snapshot_not_ready_when_not_accepting() { + let mut snap = sample_snapshot(); + snap.accepting_jobs = false; + assert!(!snap.is_ready()); + } + + #[test] + fn snapshot_clones_correctly() { + let snap = sample_snapshot(); + let clone = snap.clone(); + assert_eq!(snap.id, clone.id); + assert_eq!(snap.backend, clone.backend); + } + + #[test] + fn discovery_event_printer_added() { + let event = DiscoveryEvent::PrinterAdded(sample_snapshot()); + assert!(matches!(event, DiscoveryEvent::PrinterAdded(_))); + } + + #[test] + fn discovery_event_printer_removed() { + let event = DiscoveryEvent::PrinterRemoved { + id: "HP-123".to_string(), + backend: "CUPS".to_string(), + }; + match &event { + DiscoveryEvent::PrinterRemoved { id, backend } => { + assert_eq!(id, "HP-123"); + assert_eq!(backend, "CUPS"); + } + _ => panic!("Expected PrinterRemoved"), + } + } + + #[test] + fn discovery_event_state_changed() { + let event = DiscoveryEvent::PrinterStateChanged { + id: "HP-123".to_string(), + backend: "CUPS".to_string(), + state: "printing".to_string(), + accepting_jobs: true, + }; + match &event { + DiscoveryEvent::PrinterStateChanged { + state, + accepting_jobs, + .. + } => { + assert_eq!(state, "printing"); + assert!(*accepting_jobs); + } + _ => panic!("Expected PrinterStateChanged"), + } + } + + #[test] + fn events_are_clone() { + let event = DiscoveryEvent::PrinterAdded(sample_snapshot()); + let clone = event.clone(); + assert!(matches!(clone, DiscoveryEvent::PrinterAdded(_))); + } +} diff --git a/src/lib.rs b/src/lib.rs index bb1bdc6..986d7b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,34 +1,81 @@ -//! # cpdb-rs +//! Crate-level documentation for `cpdb-rs`. //! -//! Safe Rust bindings for OpenPrinting's -//! [`cpdb-libs`](https://github.com/OpenPrinting/cpdb-libs) — the Common -//! Print Dialog Backends library. +//! This library provides a native async Rust client for the +//! [Common Print Dialog Backends (CPDB)](https://github.com/OpenPrinting/cpdb-libs) +//! system. It communicates with CPDB backends (e.g. `cpdb-backend-cups`) +//! over D-Bus using [`zbus`], requiring no C dependencies. //! -//! See the [`Frontend`] entry point for printer discovery and the [`Printer`] -//! type for job submission, options, and translations. +//! # Features +//! +//! - `zbus-backend` *(default)* - Native async D-Bus client via [`CpdbClient`]. +//! *(Note: This uses `zbus` with the `tokio` runtime feature exclusively. Other async runtimes like `async-std` are not currently supported by the high-level client).* +//! - `ffi` - Legacy synchronous C FFI bindings via `cpdb-libs`. +//! +//! # Quick start +//! +//! ```no_run +//! # #[cfg(feature = "zbus-backend")] +//! # async fn example() -> cpdb_rs::Result<()> { +//! use cpdb_rs::CpdbClient; +//! +//! let client = CpdbClient::new().await?; +//! let printers = client.get_all_printers().await?; +//! +//! for p in &printers { +//! println!("{} [{}] - {}", p.name, p.id, p.make_model); +//! } +//! +//! if let Some(p) = printers.first() { +//! let (options, media) = client +//! .get_printer_details(&p.id, &p.backend) +//! .await?; +//! println!("{} options, {} media sizes", options.len(), media.len()); +//! } +//! # Ok(()) +//! # } +//! ``` #![cfg_attr(docsrs, feature(doc_cfg))] #![deny(missing_docs)] -pub mod callbacks; -pub mod common; pub mod error; -pub mod ffi; -pub mod frontend; pub mod options; -pub mod printer; -pub mod settings; -pub mod util; -pub use callbacks::PrinterUpdate; -pub use common::{ - absolute_path, concat_path, concat_sep, init, option_group, system_config_dir, user_config_dir, - version, -}; +#[cfg(feature = "zbus-backend")] +pub mod client; +#[cfg(feature = "zbus-backend")] +pub mod config; +#[cfg(feature = "zbus-backend")] +pub mod events; +#[cfg(feature = "zbus-backend")] +pub mod media; +#[cfg(feature = "zbus-backend")] +pub mod proxy; + +// Re-export core types for convenience. +pub use config::PrinterConfig; pub use error::{CpdbError, Result}; -pub use frontend::Frontend; +pub use events::{DiscoveryEvent, PrinterSnapshot}; +pub use media::{MarginInfo, MediaCollection, MediaInfo}; pub use options::{OptionInfo, OptionsCollection}; -pub use printer::{ - Margin, Margins, MediaSize, PrintFdHandle, PrintSocketHandle, Printer, TranslationMap, + +#[cfg(feature = "zbus-backend")] +pub use client::CpdbClient; + +#[cfg(feature = "ffi")] +pub use cpdb_sys as ffi; + +#[cfg(feature = "ffi")] +pub use cpdb_sys::callbacks::PrinterUpdate; +#[cfg(feature = "ffi")] +pub use cpdb_sys::{ + common::{ + absolute_path, concat_path, concat_sep, init, option_group, system_config_dir, + user_config_dir, version, + }, + frontend::Frontend, + printer::{ + Margin, Margins, MediaSize, PrintFdHandle, PrintSocketHandle, Printer, TranslationMap, + }, + settings::{Media, Options, Settings}, }; -pub use settings::{Media, Options, Settings}; diff --git a/src/media.rs b/src/media.rs new file mode 100644 index 0000000..8ea9f01 --- /dev/null +++ b/src/media.rs @@ -0,0 +1,194 @@ +//! Paper sizes and margins returned by `GetAllOptions`. + +#[cfg(feature = "zbus-backend")] +use crate::proxy::RawMedia; + +/// Margin values for a paper size, in hundredths of a millimetre. +#[derive(Debug, Clone)] +pub struct MarginInfo { + /// Left margin. + pub left: i32, + /// Right margin. + pub right: i32, + /// Top margin. + pub top: i32, + /// Bottom margin. + pub bottom: i32, +} + +/// A single supported paper size with its dimensions and available margins. +#[derive(Debug, Clone)] +pub struct MediaInfo { + /// The media name (e.g. `"iso_a4_210x297mm"`). + pub name: String, + /// Width in hundredths of a millimetre. + pub width: i32, + /// Length in hundredths of a millimetre. + pub length: i32, + /// Available margin configurations for this media. + pub margins: Vec, +} + +/// An owned collection of all paper sizes supported by a printer. +#[derive(Debug, Clone, Default)] +pub struct MediaCollection { + /// All supported media entries. + pub media: Vec, +} + +impl MediaCollection { + /// Build from D-Bus response (the `Vec` from GetAllOptions) + #[cfg(feature = "zbus-backend")] + pub fn from_dbus(raw: Vec) -> Self { + let media = raw + .into_iter() + .map(|r| MediaInfo { + name: r.name, + width: r.width, + length: r.length, + margins: r + .margins + .into_iter() + .map(|m| MarginInfo { + left: m.left, + right: m.right, + top: m.top, + bottom: m.bottom, + }) + .collect(), + }) + .collect(); + Self { media } + } + + /// Returns the number of media entries. + pub fn len(&self) -> usize { + self.media.len() + } + + /// Returns `true` if the collection contains no media entries. + pub fn is_empty(&self) -> bool { + self.media.is_empty() + } + + /// Finds a media entry by name (e.g. `"iso_a4_210x297mm"`). + pub fn get(&self, name: &str) -> Option<&MediaInfo> { + self.media.iter().find(|m| m.name == name) + } + + /// Returns an iterator over all media entries. + pub fn iter(&self) -> impl Iterator { + self.media.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_media() -> MediaCollection { + MediaCollection { + media: vec![ + MediaInfo { + name: "iso_a4_210x297mm".to_string(), + width: 21000, + length: 29700, + margins: vec![MarginInfo { + left: 500, + right: 500, + top: 500, + bottom: 500, + }], + }, + MediaInfo { + name: "na_letter_8.5x11in".to_string(), + width: 21590, + length: 27940, + margins: vec![], + }, + ], + } + } + + #[test] + fn empty_collection() { + let col = MediaCollection::default(); + assert!(col.is_empty()); + assert_eq!(col.len(), 0); + assert!(col.get("iso_a4_210x297mm").is_none()); + } + + #[test] + fn len_and_is_empty() { + let col = sample_media(); + assert!(!col.is_empty()); + assert_eq!(col.len(), 2); + } + + #[test] + fn get_finds_by_name() { + let col = sample_media(); + let a4 = col.get("iso_a4_210x297mm"); + assert!(a4.is_some()); + let a4 = a4.unwrap(); + assert_eq!(a4.width, 21000); + assert_eq!(a4.length, 29700); + assert_eq!(a4.margins.len(), 1); + assert_eq!(a4.margins[0].left, 500); + } + + #[test] + fn get_returns_none_for_missing() { + let col = sample_media(); + assert!(col.get("nonexistent_paper").is_none()); + } + + #[test] + fn iter_yields_all_entries() { + let col = sample_media(); + let names: Vec<&str> = col.iter().map(|m| m.name.as_str()).collect(); + assert!(names.contains(&"iso_a4_210x297mm")); + assert!(names.contains(&"na_letter_8.5x11in")); + } + + #[test] + fn media_without_margins() { + let col = sample_media(); + let letter = col.get("na_letter_8.5x11in").unwrap(); + assert!(letter.margins.is_empty()); + } + + #[cfg(feature = "zbus-backend")] + #[test] + fn from_dbus_empty_vec() { + let col = MediaCollection::from_dbus(vec![]); + assert!(col.is_empty()); + } + + #[cfg(feature = "zbus-backend")] + #[test] + fn from_dbus_converts_correctly() { + use crate::proxy::{RawMargin, RawMedia}; + + let raw = vec![RawMedia { + name: "iso_a4_210x297mm".to_string(), + width: 21000, + length: 29700, + num_margins: 1, + margins: vec![RawMargin { + left: 500, + right: 500, + top: 300, + bottom: 300, + }], + }]; + + let col = MediaCollection::from_dbus(raw); + assert_eq!(col.len(), 1); + let a4 = &col.media[0]; + assert_eq!(a4.name, "iso_a4_210x297mm"); + assert_eq!(a4.width, 21000); + assert_eq!(a4.margins.len(), 1); + assert_eq!(a4.margins[0].top, 300); + } +} diff --git a/src/options.rs b/src/options.rs index 121f448..9f0ba7f 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,13 +1,7 @@ -//! Owned, value-type snapshots of a printer's options. +//! Printer options (capabilities) returned by `GetAllOptions`. //! -//! These types decouple Rust code from the lifetime of a `cpdb_options_t` -//! by copying every field into owned Rust storage at construction. - -use crate::ffi; -use crate::util; -use glib_sys::{GHashTableIter, g_hash_table_iter_init, g_hash_table_iter_next}; -use std::mem::MaybeUninit; -use std::ptr::NonNull; +//! [`OptionsCollection`] provides an owned, framework-agnostic snapshot +//! of a printer's supported settings (copies, duplex, color mode, etc.). /// A single printer option with its supported choices. #[derive(Debug, Clone, PartialEq, Eq)] @@ -24,9 +18,25 @@ pub struct OptionInfo { /// An owned snapshot of every option in a `cpdb_options_t`. /// -/// Built by iterating `cpdb_options_t.table` once and copying every field -/// into Rust-owned `String`s. After construction the collection holds no -/// raw pointers and can be freely stored, cloned, or sent across threads. +/// Built from D-Bus `GetAllOptions` responses (zbus backend) or from +/// C `cpdb_options_t` pointers (FFI backend). After construction, no raw +/// pointers are held - the collection is freely movable and cloneable. +/// +/// # Example +/// +/// ```rust +/// use cpdb_rs::options::{OptionsCollection, OptionInfo}; +/// +/// let col = OptionsCollection { +/// options: vec![OptionInfo { +/// name: "copies".to_string(), +/// default_value: "1".to_string(), +/// group: "General".to_string(), +/// supported_values: vec!["1".to_string(), "2".to_string()], +/// }], +/// }; +/// assert_eq!(col.get("copies").unwrap().default_value, "1"); +/// ``` #[derive(Debug, Clone, Default)] pub struct OptionsCollection { /// Every option discovered, in iteration order of the underlying @@ -35,123 +45,49 @@ pub struct OptionsCollection { } impl OptionsCollection { - /// Builds an [`OptionsCollection`] by iterating `raw.table`. - /// - /// All string data is copied into owned Rust types inside this call; - /// after it returns the pointer is no longer accessed. - /// - /// # Safety - /// `raw` must point at a fully initialised `cpdb_options_t` whose - /// `table` field is null or a valid `GHashTable*` of - /// `*mut cpdb_option_t` values. - pub unsafe fn from_raw(raw: NonNull) -> Self { - // SAFETY: caller guarantees `raw` is valid. - let table = unsafe { (*raw.as_ptr()).table }; - if table.is_null() { - return Self::default(); - } - - let mut options: Vec = Vec::new(); - - // SAFETY: we initialise the iterator on the stack and iterate the - // table synchronously, copying all data into owned Strings before - // returning. Pointers obtained from `g_hash_table_iter_next` are - // borrowed into the table and must NOT be freed. - unsafe { - let mut iter = MaybeUninit::::uninit(); - g_hash_table_iter_init(iter.as_mut_ptr(), table as *mut glib_sys::GHashTable); - let mut iter = iter.assume_init(); - - let mut key: *mut libc::c_void = std::ptr::null_mut(); - let mut value: *mut libc::c_void = std::ptr::null_mut(); - - while g_hash_table_iter_next(&mut iter, &mut key, &mut value) != 0 { - if value.is_null() { - continue; - } - let opt = value as *mut ffi::cpdb_option_t; - options.push(option_info_from_raw(opt)); - } - } - + /// Builds an `OptionsCollection` from the D-Bus response tuples returned + /// by [`crate::proxy::PrintBackendProxy::get_all_options()`]. + #[cfg(feature = "zbus-backend")] + pub fn from_dbus(raw: Vec) -> Self { + let options = raw + .into_iter() + .map(|r| OptionInfo { + name: r.option_name, + group: r.group_name, + default_value: r.default_value, + supported_values: r.supported_values.into_iter().map(|(s,)| s).collect(), + }) + .collect(); Self { options } } - /// Number of options in the collection. + /// Returns the number of options in this collection. #[inline] pub fn len(&self) -> usize { self.options.len() } - /// `true` when the collection is empty. + /// Returns `true` if this collection has no options. #[inline] pub fn is_empty(&self) -> bool { self.options.is_empty() } - /// Returns the option with the given name, if any. + /// Finds an option by name (linear search). pub fn get(&self, name: &str) -> Option<&OptionInfo> { self.options.iter().find(|o| o.name == name) } - /// Iterates over every option. + /// Returns an iterator over all options. pub fn iter(&self) -> impl Iterator { self.options.iter() } } -/// Copies one `cpdb_option_t` into an owned [`OptionInfo`]. -/// -/// # Safety -/// `opt` must be a valid pointer into a live `cpdb_option_t` whose string -/// fields are NUL-terminated and whose `supported_values` array (if any) -/// has at least `num_supported` valid entries. -unsafe fn option_info_from_raw(opt: *mut ffi::cpdb_option_t) -> OptionInfo { - let name = unsafe { util::cstr_to_string((*opt).option_name) }.unwrap_or_default(); - let default_value = unsafe { util::cstr_to_string((*opt).default_value) }.unwrap_or_default(); - let group = unsafe { util::cstr_to_string((*opt).group_name) }.unwrap_or_default(); - - let mut supported_values: Vec = - Vec::with_capacity(unsafe { (*opt).num_supported } as usize); - - let arr = unsafe { (*opt).supported_values }; - let count = unsafe { (*opt).num_supported }; - if !arr.is_null() && count > 0 { - for i in 0..(count as usize) { - let s_ptr = unsafe { *arr.add(i) }; - if !s_ptr.is_null() - && let Ok(s) = unsafe { util::cstr_to_string(s_ptr) } - { - supported_values.push(s); - } - } - } - - OptionInfo { - name, - default_value, - group, - supported_values, - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn null_table_returns_empty_collection() { - let opts = ffi::cpdb_options_t { - table: std::ptr::null_mut(), - media: std::ptr::null_mut(), - count: 0, - media_count: 0, - }; - let raw = NonNull::from(&opts).cast::(); - let result = unsafe { OptionsCollection::from_raw(raw) }; - assert!(result.is_empty()); - } - #[test] fn empty_collection_helpers() { let col = OptionsCollection::default(); diff --git a/src/proxy.rs b/src/proxy.rs new file mode 100644 index 0000000..a495c09 --- /dev/null +++ b/src/proxy.rs @@ -0,0 +1,228 @@ +//! D-Bus proxy for the `org.openprinting.PrintBackend` interface. +//! +//! This module is generated by `zbus-xmlgen` and adapted to use named structs +//! for complex return types. Consumers should use [`crate::client::CpdbClient`] +//! instead of this proxy directly. + +// The `#[proxy]` macro generates internal struct fields and service methods +// that cannot be given doc comments directly. +#![allow(missing_docs)] + +use serde::{Deserialize, Serialize}; +use zbus::proxy; +use zbus::zvariant::Type; + +/// A printer option from `GetAllOptions`. D-Bus signature: `(sssia(s))`. +#[derive(Debug, Clone, Deserialize, Serialize, Type)] +pub struct RawOption { + /// The option name (e.g. `"sides"`, `"copies"`). + pub option_name: String, + /// The option group name (e.g. `"General"`). + pub group_name: String, + /// The default value string. + pub default_value: String, + /// Number of supported values. + pub num_supported: i32, + /// The list of supported values as 1-tuples (D-Bus encoding). + pub supported_values: Vec<(String,)>, +} + +/// Print margins in hundredths of a millimetre. D-Bus signature: `(iiii)`. +#[derive(Debug, Clone, Deserialize, Serialize, Type)] +pub struct RawMargin { + /// Left margin in hundredths of a mm. + pub left: i32, + /// Right margin in hundredths of a mm. + pub right: i32, + /// Top margin in hundredths of a mm. + pub top: i32, + /// Bottom margin in hundredths of a mm. + pub bottom: i32, +} + +/// A supported paper size from `GetAllOptions`. D-Bus signature: `(siiia(iiii))`. +/// +/// Width and length are in hundredths of a millimetre. +#[derive(Debug, Clone, Deserialize, Serialize, Type)] +pub struct RawMedia { + /// The media name (e.g. `"iso_a4_210x297mm"`). + pub name: String, + /// Width in hundredths of a mm. + pub width: i32, + /// Length in hundredths of a mm. + pub length: i32, + /// Number of margin entries. + pub num_margins: i32, + /// Available margin configurations. + pub margins: Vec, +} + +/// A printer summary from `GetAllPrinters` or `GetFilteredPrinterList`. +/// D-Bus signature: `(sssssbss)`. +#[derive(Debug, Clone, Deserialize, Serialize, Type)] +pub struct RawPrinter( + pub String, // id + pub String, // name + pub String, // info + pub String, // location + pub String, // make_and_model + pub bool, // is_accepting_jobs + pub String, // state + pub String, // backend_name +); + +#[allow(missing_docs)] +/// Low-level proxy for `org.openprinting.PrintBackend`. +/// +/// Prefer [`CpdbClient`](crate::client::CpdbClient) for high-level usage. +#[proxy(interface = "org.openprinting.PrintBackend", assume_defaults = true)] +pub trait PrintBackend { + /// Returns all printers known to this backend. + fn get_all_printers(&self) -> zbus::Result<(i32, Vec)>; + + /// Returns the backend name (e.g. "CUPS", "FILE"). + fn get_backend_name(&self) -> zbus::Result; + + /// Returns printers matching the current filter settings. + fn get_filtered_printer_list(&self) -> zbus::Result<(i32, Vec)>; + + /// Starts or stops printer discovery. Call `do_listing(true)` to trigger + /// `PrinterAdded` signals for all known printers. + /// + /// This is a fire-and-forget method - the backend does not send a reply. + #[zbus(name = "doListing", no_reply)] + fn do_listing(&self, is_listed: bool) -> zbus::Result<()>; + + /// Fetches all options and media for a printer in a single call. + /// + /// Returns `(num_options, options, num_media, media)`. + /// This is the primary method for getting printer capabilities. + #[allow(clippy::type_complexity)] + fn get_all_options( + &self, + printer_id: &str, + ) -> zbus::Result<(i32, Vec, i32, Vec)>; + + /// Fetches all localized translations for a printer's options. + /// + /// Returns a map of internal name -> human-readable label. + fn get_all_translations( + &self, + printer_id: &str, + locale: &str, + ) -> zbus::Result>; + + /// Returns the default printer ID for this backend. + #[zbus(name = "getDefaultPrinter")] + fn get_default_printer(&self) -> zbus::Result; + + /// Returns the current state of a printer (e.g. "idle", "printing"). + #[zbus(name = "getPrinterState")] + fn get_printer_state(&self, printer_id: &str) -> zbus::Result; + + /// Returns whether a printer is currently accepting jobs. + #[zbus(name = "isAcceptingJobs")] + fn is_accepting_jobs(&self, printer_id: &str) -> zbus::Result; + + /// Returns a localized label for a single option name. + #[zbus(name = "getOptionTranslation")] + fn get_option_translation( + &self, + printer_name: &str, + option_name: &str, + locale: &str, + ) -> zbus::Result; + + /// Returns a localized label for a single option choice. + #[zbus(name = "getChoiceTranslation")] + fn get_choice_translation( + &self, + printer_name: &str, + option_name: &str, + choice_name: &str, + locale: &str, + ) -> zbus::Result; + + /// Returns a localized label for an option group. + #[zbus(name = "getGroupTranslation")] + fn get_group_translation( + &self, + printer_name: &str, + group_name: &str, + locale: &str, + ) -> zbus::Result; + + /// Submits a print job and returns a writable file descriptor. + /// + /// The backend creates a job and returns the write end of a socketpair. + /// The caller writes document data into `fd` and closes it when done. + #[zbus(name = "printFd")] + fn print_fd( + &self, + printer_id: &str, + num_settings: i32, + settings: &[(&str, &str)], + title: &str, + ) -> zbus::Result<(String, zbus::zvariant::OwnedFd)>; + + /// Submits a print job via a Unix socket path. + #[zbus(name = "printSocket")] + fn print_socket( + &self, + printer_id: &str, + num_settings: i32, + settings: &[(&str, &str)], + title: &str, + ) -> zbus::Result<(String, String)>; + + /// Show or hide remote (network-discovered) printers. + /// + /// This is a fire-and-forget method. The backend will emit `PrinterRemoved` + /// or `PrinterAdded` signals to reflect the new filtered state. + #[zbus(name = "showRemotePrinters", no_reply)] + fn show_remote_printers(&self, is_visible: bool) -> zbus::Result<()>; + + /// Show or hide temporary (auto-discovered) printers. + #[zbus(name = "showTemporaryPrinters", no_reply)] + fn show_temporary_printers(&self, is_visible: bool) -> zbus::Result<()>; + + /// Keeps the backend alive (prevents auto-exit timeout). + #[zbus(name = "keepAlive")] + fn keep_alive(&self) -> zbus::Result<()>; + + /// Tells the backend to replace state from a previous dialog session. + #[zbus(name = "replace", no_reply)] + fn replace(&self, previous_dialog_id: &str) -> zbus::Result<()>; + + /// Ping (testing only, will be removed from spec). + #[zbus(name = "ping", no_reply)] + fn ping(&self, printer_id: &str) -> zbus::Result<()>; + + /// Emitted when a new printer is discovered by this backend. + #[zbus(signal)] + fn printer_added( + &self, + printer_id: &str, + printer_name: &str, + printer_info: &str, + printer_location: &str, + printer_make_and_model: &str, + printer_is_accepting_jobs: bool, + printer_state: &str, + backend_name: &str, + ) -> zbus::Result<()>; + + /// Emitted when a printer is removed from this backend. + #[zbus(signal)] + fn printer_removed(&self, printer_id: &str, backend_name: &str) -> zbus::Result<()>; + + /// Emitted when a printer's state changes (e.g. idle -> printing). + #[zbus(signal)] + fn printer_state_changed( + &self, + printer_id: &str, + printer_state: &str, + printer_is_accepting_jobs: bool, + backend_name: &str, + ) -> zbus::Result<()>; +} diff --git a/tests/integration.rs b/tests/integration.rs index f4afe6d..1b67e88 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -2,47 +2,333 @@ //! require a session D-Bus and at least one cpdb backend to be active. //! Run with `cargo test -- --ignored`. -use cpdb_rs::Frontend; -use std::fs; -use std::io::Write; - -fn write_temp_test_file(name: &str) -> std::path::PathBuf { - let mut path = std::env::temp_dir(); - path.push(name); - let mut f = fs::File::create(&path).expect("failed to create test print file"); - writeln!(f, "cpdb-rs integration test").unwrap(); - path -} +#[cfg(all(test, feature = "ffi"))] +mod ffi_integration { + use cpdb_rs::Frontend; + use std::fs; + use std::io::Write; + + fn write_temp_test_file(name: &str) -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + path.push(name); + let mut f = fs::File::create(&path).expect("failed to create test print file"); + writeln!(f, "cpdb-rs integration test").unwrap(); + path + } + + #[test] + #[ignore] + fn printer_discovery() { + cpdb_rs::init(); + let frontend = Frontend::new().expect("frontend init failed"); + frontend.connect_to_dbus().expect("connect_to_dbus failed"); + let printers = frontend.get_printers().expect("get_printers failed"); + for p in &printers { + let name = p.name().unwrap_or_default(); + let state = p.get_updated_state().unwrap_or_default(); + eprintln!("found {name}: {state}"); + } + } -#[test] -#[ignore] -fn printer_discovery() { - cpdb_rs::init(); - let frontend = Frontend::new().expect("frontend init failed"); - frontend.connect_to_dbus().expect("connect_to_dbus failed"); - let printers = frontend.get_printers().expect("get_printers failed"); - for p in &printers { - let name = p.name().unwrap_or_default(); - let state = p.get_updated_state().unwrap_or_default(); - eprintln!("found {name}: {state}"); + #[test] + #[ignore] + fn job_submission_applies_options() { + cpdb_rs::init(); + let frontend = Frontend::new().expect("frontend init failed"); + frontend.connect_to_dbus().expect("connect_to_dbus failed"); + let printers = frontend.get_printers().unwrap(); + let printer = match printers.first() { + Some(p) => p, + None => return, // no printer in CI is fine + }; + let file = write_temp_test_file("cpdb-rs-test.txt"); + let job_id = printer + .submit_job(file.to_str().unwrap(), &[("copies", "1")], "cpdb-rs test") + .expect("submit_job failed"); + assert!(!job_id.is_empty(), "job id must not be empty"); + let _ = fs::remove_file(&file); } } -#[test] -#[ignore] -fn job_submission_applies_options() { - cpdb_rs::init(); - let frontend = Frontend::new().expect("frontend init failed"); - frontend.connect_to_dbus().expect("connect_to_dbus failed"); - let printers = frontend.get_printers().unwrap(); - let printer = match printers.first() { - Some(p) => p, - None => return, // no printer in CI is fine - }; - let file = write_temp_test_file("cpdb-rs-test.txt"); - let job_id = printer - .submit_job(file.to_str().unwrap(), &[("copies", "1")], "cpdb-rs test") - .expect("submit_job failed"); - assert!(!job_id.is_empty(), "job id must not be empty"); - let _ = fs::remove_file(&file); +#[cfg(all(test, feature = "zbus-backend"))] +mod zbus_integration { + use cpdb_rs::client::CpdbClient; + use cpdb_rs::events::DiscoveryEvent; + use futures_util::StreamExt; + + // Test that CpdbClient can connect to the session bus and discover backends. + // This test requires a running D-Bus session bus and at least one CPDB + // backend installed (e.g. cpdb-backend-cups). + #[tokio::test] + #[ignore] + async fn test_client_connects_and_discovers_backends() { + let client = CpdbClient::new().await; + match client { + Ok(client) => { + println!("Connected! Found {} backend(s)", client.backend_count()); + assert!( + client.backend_count() > 0, + "Expected at least 1 CPDB backend" + ); + } + Err(e) => { + eprintln!("CpdbClient::new() failed: {e}"); + } + } + } + + // Test that the discovery stream produces PrinterAdded events. + // This test starts a discovery stream and waits for up to 5 seconds + // for at least one PrinterAdded event. + #[tokio::test] + #[ignore] + async fn test_discovery_stream_emits_events() { + let client = match CpdbClient::new().await { + Ok(c) => c, + Err(e) => { + eprintln!("Skipping: CpdbClient::new() failed: {e}"); + return; + } + }; + + if client.backend_count() == 0 { + eprintln!("Skipping: no CPDB backends found"); + return; + } + + let stream = match client.discovery_stream().await { + Ok(s) => s, + Err(e) => { + eprintln!("Skipping: discovery_stream() failed: {e}"); + return; + } + }; + + // Take up to 10 events or timeout after 5 seconds + let events: Vec = + tokio::time::timeout(std::time::Duration::from_secs(5), stream.take(10).collect()) + .await + .unwrap_or_default(); + + println!("Received {} discovery event(s):", events.len()); + for event in &events { + match event { + DiscoveryEvent::PrinterAdded(snap) => { + println!( + " + PrinterAdded: id={}, name={}, backend={}, state={}", + snap.id, snap.name, snap.backend, snap.state + ); + } + DiscoveryEvent::PrinterRemoved { id, backend } => { + println!(" - PrinterRemoved: id={id}, backend={backend}"); + } + DiscoveryEvent::PrinterStateChanged { + id, state, backend, .. + } => { + println!(" ~ StateChanged: id={id}, state={state}, backend={backend}"); + } + } + } + + // We expect at least one PrinterAdded if backends + printers exist + if !events.is_empty() { + assert!( + events + .iter() + .any(|e| matches!(e, DiscoveryEvent::PrinterAdded(_))), + "Expected at least one PrinterAdded event" + ); + } + } + + // Test fetching printer capabilities (options + media) for a discovered printer. + #[tokio::test] + #[ignore] + async fn test_get_printer_details() { + let client = match CpdbClient::new().await { + Ok(c) => c, + Err(e) => { + eprintln!("Skipping: {e}"); + return; + } + }; + + if client.backend_count() == 0 { + eprintln!("Skipping: no backends"); + return; + } + + // First discover a printer + let stream = match client.discovery_stream().await { + Ok(s) => s, + Err(e) => { + eprintln!("Skipping: {e}"); + return; + } + }; + + use futures_util::stream::StreamExt as _; + let mut filtered = stream + .filter_map(|e| async move { + match e { + DiscoveryEvent::PrinterAdded(snap) => Some(snap), + _ => None, + } + }) + .boxed(); + let first_printer = + tokio::time::timeout(std::time::Duration::from_secs(5), filtered.next()).await; + + let snap = match first_printer { + Ok(Some(s)) => s, + _ => { + eprintln!("Skipping: no printer discovered within 5s"); + return; + } + }; + + println!( + "Fetching details for: {} (backend: {})", + snap.id, snap.backend + ); + + match client.get_printer_details(&snap.id, &snap.backend).await { + Ok((options, media)) => { + println!(" Options: {} entries", options.len()); + for opt in options.iter().take(5) { + println!( + " {}: default={}, choices=[{}]", + opt.name, + opt.default_value, + opt.supported_values.join(", ") + ); + } + + println!(" Media: {} entries", media.len()); + for m in media.iter().take(5) { + println!( + " {}: {}x{} ({} margin set(s))", + m.name, + m.width, + m.length, + m.margins.len() + ); + } + + assert!(!options.is_empty(), "Expected at least one option"); + } + Err(e) => { + eprintln!("get_printer_details failed: {e}"); + } + } + } + + // Test fetching translations for a discovered printer. + #[tokio::test] + #[ignore] + async fn test_get_translations() { + let client = match CpdbClient::new().await { + Ok(c) => c, + Err(e) => { + eprintln!("Skipping: {e}"); + return; + } + }; + + if client.backend_count() == 0 { + eprintln!("Skipping: no backends"); + return; + } + + // Discover a printer + let stream = match client.discovery_stream().await { + Ok(s) => s, + Err(e) => { + eprintln!("Skipping: {e}"); + return; + } + }; + + let mut filtered = stream + .filter_map(|e| async move { + match e { + DiscoveryEvent::PrinterAdded(snap) => Some(snap), + _ => None, + } + }) + .boxed(); + let first_printer = + tokio::time::timeout(std::time::Duration::from_secs(5), filtered.next()).await; + + let snap = match first_printer { + Ok(Some(s)) => s, + _ => { + eprintln!("Skipping: no printer discovered within 5s"); + return; + } + }; + + match client + .get_translations(&snap.id, &snap.backend, "en_US") + .await + { + Ok(translations) => { + println!("Translations ({} entries):", translations.len()); + for (k, v) in translations.iter().take(10) { + println!(" {} -> {}", k, v); + } + } + Err(e) => { + eprintln!("get_translations failed (may not be supported): {e}"); + } + } + } + + // Test the default printer query. + #[tokio::test] + #[ignore] + async fn test_get_default_printer() { + let client = match CpdbClient::new().await { + Ok(c) => c, + Err(e) => { + eprintln!("Skipping: {e}"); + return; + } + }; + + if client.backend_count() == 0 { + eprintln!("Skipping: no backends"); + return; + } + + match client.get_default_printer("CUPS").await { + Ok(default) => { + println!("Default CUPS printer: {}", default); + assert!(!default.is_empty()); + } + Err(e) => { + eprintln!("get_default_printer failed: {e}"); + } + } + } + + // Test remote/temporary printer visibility toggles. + #[tokio::test] + #[ignore] + async fn test_show_remote_and_temporary_printers() { + let client = match CpdbClient::new().await { + Ok(c) => c, + Err(e) => { + eprintln!("Skipping: {e}"); + return; + } + }; + + // These should not panic even with no backends + client.show_remote_printers(false).await; + client.show_remote_printers(true).await; + client.show_temporary_printers(false).await; + client.show_temporary_printers(true).await; + println!("Visibility toggles completed without error"); + } } diff --git a/tests/unit_tests.rs b/tests/unit_tests.rs index e568dea..ef950e2 100644 --- a/tests/unit_tests.rs +++ b/tests/unit_tests.rs @@ -5,70 +5,6 @@ //! suite but are skipped under `cargo miri test`. use cpdb_rs::error::CpdbError; -use cpdb_rs::{Settings, init, util, version}; -use std::ffi::CString; - -#[test] -#[cfg_attr(miri, ignore)] -fn init_is_idempotent() { - init(); - init(); -} - -#[test] -#[cfg_attr(miri, ignore)] -fn version_is_non_empty_when_present() { - init(); - if let Ok(v) = version() { - assert!(!v.is_empty(), "version string must not be empty"); - } -} - -#[test] -#[cfg_attr(miri, ignore)] -fn settings_lifecycle() { - init(); - let mut s = Settings::new().expect("Settings::new failed"); - s.add_setting("copies", "1").unwrap(); - let existed = s.clear_setting("copies").unwrap(); - assert!(existed, "the key we just inserted should have existed"); - let again = s.clear_setting("copies").unwrap(); - assert!(!again, "clearing a missing key should return false"); -} - -#[test] -#[cfg_attr(miri, ignore)] -fn settings_try_clone_is_independent() { - init(); - let mut a = Settings::new().expect("Settings::new failed"); - a.add_setting("media", "iso_a4_210x297mm").unwrap(); - let mut b = a.try_clone().expect("try_clone failed"); - // Modifying the clone must not affect the original. - let _ = b.clear_setting("media").unwrap(); - // Sanity: the original still works. - let _ = a.clear_setting("media").unwrap(); -} - -#[test] -fn cstr_to_string_handles_valid_input() { - let cstring = CString::new("hello").unwrap(); - let out = unsafe { util::cstr_to_string(cstring.as_ptr()) }.unwrap(); - assert_eq!(out, "hello"); -} - -#[test] -fn cstr_to_string_rejects_null() { - let result = unsafe { util::cstr_to_string(std::ptr::null()) }; - assert!(matches!(result, Err(CpdbError::NullPointer))); -} - -#[test] -fn to_c_options_round_trips() { - let pairs = &[("copies", "2"), ("sides", "two-sided-long-edge")]; - let opts = util::to_c_options(pairs).unwrap(); - assert_eq!(opts.len(), pairs.len()); - assert!(!opts.is_empty()); -} #[test] fn error_messages_are_stable() { @@ -89,3 +25,603 @@ fn error_messages_are_stable() { "Print job failed: oops" ); } + +#[cfg(all(test, feature = "ffi"))] +mod ffi_tests { + use cpdb_rs::ffi::error::CpdbError; + use cpdb_rs::ffi::util; + use cpdb_rs::{Frontend, Options, Settings, init, version}; + use std::ffi::CString; + use tempfile::NamedTempFile; + + fn setup_test_environment() { + init(); + } + + #[test] + #[cfg_attr(miri, ignore)] + fn init_is_idempotent() { + init(); + init(); + } + + #[test] + #[cfg_attr(miri, ignore)] + fn version_is_non_empty_when_present() { + init(); + if let Ok(v) = version() { + assert!(!v.is_empty(), "version string must not be empty"); + } + } + + #[test] + #[cfg_attr(miri, ignore)] + fn settings_lifecycle() { + init(); + let mut s = Settings::new().expect("Settings::new failed"); + s.add_setting("copies", "1").unwrap(); + let existed = s.clear_setting("copies").unwrap(); + assert!(existed, "the key we just inserted should have existed"); + let again = s.clear_setting("copies").unwrap(); + assert!(!again, "clearing a missing key should return false"); + } + + #[test] + #[cfg_attr(miri, ignore)] + fn settings_try_clone_is_independent() { + init(); + let mut a = Settings::new().expect("Settings::new failed"); + a.add_setting("media", "iso_a4_210x297mm").unwrap(); + let mut b = a.try_clone().expect("try_clone failed"); + // Modifying the clone must not affect the original. + let _ = b.clear_setting("media").unwrap(); + // Sanity: the original still works. + let _ = a.clear_setting("media").unwrap(); + } + + #[test] + fn cstr_to_string_handles_valid_input() { + let cstring = CString::new("hello").unwrap(); + let out = unsafe { util::cstr_to_string(cstring.as_ptr()) }.unwrap(); + assert_eq!(out, "hello"); + } + + #[test] + fn cstr_to_string_rejects_null() { + let result = unsafe { util::cstr_to_string(std::ptr::null()) }; + assert!(matches!(result, Err(CpdbError::NullPointer))); + } + + #[test] + fn to_c_options_round_trips() { + let pairs = &[("copies", "2"), ("sides", "two-sided-long-edge")]; + let opts = util::to_c_options(pairs).unwrap(); + assert_eq!(opts.len(), pairs.len()); + assert!(!opts.is_empty()); + } + + #[test] + fn test_library_initialization() { + setup_test_environment(); + // init() should not panic + init(); + } + + #[test] + fn test_version_retrieval() { + setup_test_environment(); + match version() { + Ok(v) => { + assert!(!v.is_empty(), "Version string should not be empty"); + println!("CPDB version: {}", v); + } + Err(e) => { + // Version retrieval might fail in test environment + println!( + "Version retrieval failed (expected in test environment): {}", + e + ); + } + } + } + + #[test] + fn test_frontend_creation() { + setup_test_environment(); + match Frontend::new() { + Ok(frontend) => { + // Frontend created successfully + let _ = frontend; + } + Err(e) => { + // Frontend creation might fail in test environment + println!( + "Frontend creation failed (expected in test environment): {}", + e + ); + } + } + } + + #[test] + fn test_settings_creation() { + setup_test_environment(); + match Settings::new() { + Ok(settings) => { + // Settings should be created successfully + assert!(!settings.as_raw().is_null()); + } + Err(e) => { + // Settings creation might fail in test environment + println!( + "Settings creation failed (expected in test environment): {}", + e + ); + } + } + } + + #[test] + fn test_settings_operations() { + setup_test_environment(); + match Settings::new() { + Ok(mut settings) => { + // Test adding a setting + assert!(settings.add_setting("test_key", "test_value").is_ok()); + + // Test clearing a setting + assert!(settings.clear_setting("test_key").is_ok()); + + // Test copying settings + match settings.try_clone() { + Ok(copy) => { + assert!(!copy.as_raw().is_null()); + } + Err(e) => { + println!("Settings copy failed (expected in test environment): {}", e); + } + } + } + Err(e) => { + println!( + "Settings creation failed (expected in test environment): {}", + e + ); + } + } + } + + #[test] + fn test_options_creation() { + setup_test_environment(); + match Options::new() { + Ok(options) => { + // Options should be created successfully + assert!(!options.as_raw().is_null()); + } + Err(e) => { + // Options creation might fail in test environment + println!( + "Options creation failed (expected in test environment): {}", + e + ); + } + } + } + + #[test] + fn test_settings_file_operations() { + setup_test_environment(); + + // Create a temporary file for testing + let _temp_file = NamedTempFile::new().expect("Failed to create temp file"); + + match Settings::new() { + Ok(mut settings) => { + // Add some test settings + assert!(settings.add_setting("test_key1", "test_value1").is_ok()); + assert!(settings.add_setting("test_key2", "test_value2").is_ok()); + + // Test saving to disk (no path needed) + match settings.save_to_disk() { + Ok(_) => { + // Test loading from disk + match Settings::read_from_disk() { + Ok(loaded_settings) => { + assert!(!loaded_settings.as_raw().is_null()); + println!("Settings file operations successful"); + } + Err(e) => { + println!( + "Settings load from disk failed (expected in test environment): {}", + e + ); + } + } + } + Err(e) => { + println!( + "Settings save to disk failed (expected in test environment): {}", + e + ); + } + } + } + Err(e) => { + println!( + "Settings creation failed (expected in test environment): {}", + e + ); + } + } + } + + #[test] + fn test_string_conversion_utilities() { + use cpdb_rs::ffi::util; + use std::ffi::CString; + + // Test valid C string conversion + let test_string = "Hello, World!"; + let c_string = CString::new(test_string).expect("Failed to create CString"); + let result = unsafe { util::cstr_to_string(c_string.as_ptr()) }; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), test_string); + + // Test null pointer handling + let null_result = unsafe { util::cstr_to_string(std::ptr::null()) }; + assert!(null_result.is_err()); + assert!(matches!(null_result.unwrap_err(), CpdbError::NullPointer)); + } + + #[test] + fn test_c_options_conversion() { + use cpdb_rs::ffi::util; + + let options = &[("key1", "value1"), ("key2", "value2")]; + match util::to_c_options(options) { + Ok(c_options) => { + assert_eq!(c_options.len(), 2); + } + Err(e) => { + println!( + "C options conversion failed (expected in test environment): {}", + e + ); + } + } + } + + #[test] + fn test_resource_cleanup() { + setup_test_environment(); + + // Test that resources are properly cleaned up + // This is mainly to ensure Drop implementations don't panic + let _settings = Settings::new(); + let _options = Options::new(); + let _frontend = Frontend::new(); + println!("Resource cleanup tests completed"); + } +} + +// zbus backend unit tests + +#[cfg(all(test, feature = "zbus-backend"))] +mod zbus_tests { + use cpdb_rs::config::PrinterConfig; + use cpdb_rs::error::CpdbError; + use cpdb_rs::events::{DiscoveryEvent, PrinterSnapshot}; + use cpdb_rs::media::MediaCollection; + use cpdb_rs::options::OptionsCollection; + use cpdb_rs::proxy::{RawMargin, RawMedia, RawOption}; + use std::collections::HashMap; + + #[test] + fn raw_option_debug_display() { + let opt = RawOption { + option_name: "copies".to_string(), + group_name: "General".to_string(), + default_value: "1".to_string(), + num_supported: 2, + supported_values: vec![("1".to_string(),), ("2".to_string(),)], + }; + let debug = format!("{:?}", opt); + assert!(debug.contains("copies")); + } + + #[test] + fn raw_media_with_margins() { + let media = RawMedia { + name: "iso_a4_210x297mm".to_string(), + width: 21000, + length: 29700, + num_margins: 1, + margins: vec![RawMargin { + left: 500, + right: 500, + top: 300, + bottom: 300, + }], + }; + assert_eq!(media.name, "iso_a4_210x297mm"); + assert_eq!(media.margins.len(), 1); + assert_eq!(media.margins[0].left, 500); + } + + #[test] + fn raw_option_clone() { + let opt = RawOption { + option_name: "sides".to_string(), + group_name: "General".to_string(), + default_value: "one-sided".to_string(), + num_supported: 1, + supported_values: vec![("one-sided".to_string(),)], + }; + let clone = opt.clone(); + assert_eq!(clone.option_name, "sides"); + assert_eq!(clone.supported_values.len(), 1); + } + + #[test] + fn options_from_dbus_empty() { + let col = OptionsCollection::from_dbus(vec![]); + assert!(col.is_empty()); + assert_eq!(col.len(), 0); + } + + #[test] + fn options_from_dbus_single_option() { + let raw = vec![RawOption { + option_name: "copies".to_string(), + group_name: "General".to_string(), + default_value: "1".to_string(), + num_supported: 3, + supported_values: vec![("1".to_string(),), ("2".to_string(),), ("99".to_string(),)], + }]; + let col = OptionsCollection::from_dbus(raw); + assert_eq!(col.len(), 1); + + let opt = col.get("copies").unwrap(); + assert_eq!(opt.default_value, "1"); + assert_eq!(opt.group, "General"); + // Verify the Vec<(String,)> -> Vec extraction + assert_eq!(opt.supported_values, vec!["1", "2", "99"]); + } + + #[test] + fn options_from_dbus_multiple_options() { + let raw = vec![ + RawOption { + option_name: "copies".to_string(), + group_name: "General".to_string(), + default_value: "1".to_string(), + num_supported: 1, + supported_values: vec![("1".to_string(),)], + }, + RawOption { + option_name: "sides".to_string(), + group_name: "General".to_string(), + default_value: "one-sided".to_string(), + num_supported: 3, + supported_values: vec![ + ("one-sided".to_string(),), + ("two-sided-long-edge".to_string(),), + ("two-sided-short-edge".to_string(),), + ], + }, + RawOption { + option_name: "print-color-mode".to_string(), + group_name: "Color".to_string(), + default_value: "color".to_string(), + num_supported: 2, + supported_values: vec![("color".to_string(),), ("monochrome".to_string(),)], + }, + ]; + let col = OptionsCollection::from_dbus(raw); + assert_eq!(col.len(), 3); + + // Verify each option + assert!(col.get("copies").is_some()); + assert!(col.get("sides").is_some()); + assert!(col.get("print-color-mode").is_some()); + + let sides = col.get("sides").unwrap(); + assert_eq!(sides.supported_values.len(), 3); + assert!( + sides + .supported_values + .contains(&"two-sided-long-edge".to_string()) + ); + } + + #[test] + fn options_from_dbus_preserves_empty_supported_values() { + let raw = vec![RawOption { + option_name: "resolution".to_string(), + group_name: "Quality".to_string(), + default_value: "300dpi".to_string(), + num_supported: 0, + supported_values: vec![], + }]; + let col = OptionsCollection::from_dbus(raw); + let opt = col.get("resolution").unwrap(); + assert!(opt.supported_values.is_empty()); + } + + #[test] + fn media_from_dbus_empty() { + let col = MediaCollection::from_dbus(vec![]); + assert!(col.is_empty()); + } + + #[test] + fn media_from_dbus_with_margins() { + let raw = vec![RawMedia { + name: "iso_a4_210x297mm".to_string(), + width: 21000, + length: 29700, + num_margins: 2, + margins: vec![ + RawMargin { + left: 500, + right: 500, + top: 300, + bottom: 300, + }, + RawMargin { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + ], + }]; + let col = MediaCollection::from_dbus(raw); + assert_eq!(col.len(), 1); + + let a4 = col.get("iso_a4_210x297mm").unwrap(); + assert_eq!(a4.width, 21000); + assert_eq!(a4.length, 29700); + assert_eq!(a4.margins.len(), 2); + // First margin: standard + assert_eq!(a4.margins[0].left, 500); + // Second margin: borderless + assert_eq!(a4.margins[1].left, 0); + } + + #[test] + fn media_from_dbus_without_margins() { + let raw = vec![RawMedia { + name: "na_letter_8.5x11in".to_string(), + width: 21590, + length: 27940, + num_margins: 0, + margins: vec![], + }]; + let col = MediaCollection::from_dbus(raw); + let letter = col.get("na_letter_8.5x11in").unwrap(); + assert!(letter.margins.is_empty()); + } + + #[test] + fn printer_snapshot_is_ready() { + let snap = PrinterSnapshot { + id: "test".to_string(), + name: "Test".to_string(), + info: String::new(), + location: String::new(), + make_model: String::new(), + state: "idle".to_string(), + accepting_jobs: true, + backend: "CUPS".to_string(), + }; + assert!(snap.is_ready()); + } + + #[test] + fn discovery_event_pattern_matching() { + let events = vec![ + DiscoveryEvent::PrinterAdded(PrinterSnapshot { + id: "printer-1".to_string(), + name: "My Printer".to_string(), + info: String::new(), + location: String::new(), + make_model: String::new(), + state: "idle".to_string(), + accepting_jobs: true, + backend: "CUPS".to_string(), + }), + DiscoveryEvent::PrinterStateChanged { + id: "printer-1".to_string(), + backend: "CUPS".to_string(), + state: "printing".to_string(), + accepting_jobs: true, + }, + DiscoveryEvent::PrinterRemoved { + id: "printer-1".to_string(), + backend: "CUPS".to_string(), + }, + ]; + + let mut printers: Vec = Vec::new(); + + for event in events { + match event { + DiscoveryEvent::PrinterAdded(snap) => { + printers.push(snap); + } + DiscoveryEvent::PrinterStateChanged { + id, + backend, + state, + accepting_jobs, + } => { + if let Some(p) = printers + .iter_mut() + .find(|p| p.id == id && p.backend == backend) + { + p.state = state; + p.accepting_jobs = accepting_jobs; + } + } + DiscoveryEvent::PrinterRemoved { id, backend } => { + printers.retain(|p| !(p.id == id && p.backend == backend)); + } + } + } + + // After all events: printer was added, state changed, then removed + assert!(printers.is_empty()); + } + + #[test] + fn config_roundtrip_preserves_all_fields() { + let mut settings = HashMap::new(); + settings.insert("copies".to_string(), "5".to_string()); + settings.insert("media".to_string(), "iso_a4_210x297mm".to_string()); + settings.insert("sides".to_string(), "two-sided-long-edge".to_string()); + settings.insert("print-color-mode".to_string(), "monochrome".to_string()); + + let config = PrinterConfig::new("HP-LaserJet", "CUPS", settings); + let json = serde_json::to_string_pretty(&config).unwrap(); + let loaded: PrinterConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(loaded.last_printer_id.as_deref(), Some("HP-LaserJet")); + assert_eq!(loaded.last_backend.as_deref(), Some("CUPS")); + assert_eq!(loaded.get_setting("copies"), Some("5")); + assert_eq!(loaded.get_setting("sides"), Some("two-sided-long-edge")); + } + + #[test] + fn error_display_messages() { + // Verify all error variants format correctly + assert_eq!( + format!("{}", CpdbError::NullPointer), + "Null pointer encountered" + ); + assert_eq!( + format!("{}", CpdbError::BackendError("CUPS down".into())), + "Backend error: CUPS down" + ); + assert_eq!( + format!("{}", CpdbError::Unsupported), + "Unsupported operation" + ); + } + + #[test] + fn error_from_status_mapping() { + let _null = CpdbError::NullPointer; + let _invalid = CpdbError::InvalidPrinter; + let _job = CpdbError::JobFailed("test".into()); + let _backend = CpdbError::BackendError("unknown".into()); + } + + #[test] + fn error_is_non_exhaustive() { + // This test just ensures the enum compiles with #[non_exhaustive] + let err: CpdbError = CpdbError::BackendError("test".into()); + let _msg = format!("{}", err); + } +}