From 994b42c15c2efb98ca281a53d07534c8434341ed Mon Sep 17 00:00:00 2001 From: jcl-bit <336218001+jcl-bit@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:09:23 -0700 Subject: [PATCH] CLI: pdfcraft-cli.exe carries a Windows version resource (#598) pdfcraft-cli.exe had no VERSIONINFO, so Explorer's Details tab, (Get-Item pdfcraft-cli.exe).VersionInfo and inventory tools showed no version or publisher for it. A build.rs like the app's embeds one with winresource (already a build dependency of pdfcraft): the same product name and copyright, its own description and file names, and the package version. PDFCRAFT_REQUIRE_WINRES=1, which the Windows packaging already sets, makes a missing resource compiler an error; otherwise it's a warning, and other targets are unaffected. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- Cargo.lock | 1 + apps/pdfcraft-cli/Cargo.toml | 4 ++ apps/pdfcraft-cli/build.rs | 28 +++++++++++ apps/pdfcraft-cli/tests/version_resource.rs | 54 +++++++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 apps/pdfcraft-cli/build.rs create mode 100644 apps/pdfcraft-cli/tests/version_resource.rs diff --git a/Cargo.lock b/Cargo.lock index 2dcd5d7ecf..fb4101092c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3606,6 +3606,7 @@ dependencies = [ "pdfcraft-render", "rustix 1.1.5", "serde_json", + "winresource", ] [[package]] diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index b0f23963fc..77dfe2f9fd 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -25,6 +25,10 @@ base64 = "0.22" # Already in Cargo.lock through the GUI crates. rustix = { version = "1", features = ["process"] } +[build-dependencies] +# VERSIONINFO for pdfcraft-cli.exe; build.rs only uses it when targeting Windows. +winresource = "0.1" + [features] default = ["mcp"] # The opt-in MCP server (`pdfcraft-cli mcp`). Build with --no-default-features to leave it out. diff --git a/apps/pdfcraft-cli/build.rs b/apps/pdfcraft-cli/build.rs new file mode 100644 index 0000000000..ee77f13f6a --- /dev/null +++ b/apps/pdfcraft-cli/build.rs @@ -0,0 +1,28 @@ +//! Windows only: embed version info (VERSIONINFO) into `pdfcraft-cli.exe`, so Explorer's +//! Details tab, `(Get-Item pdfcraft-cli.exe).VersionInfo` and inventory tools can read the version +//! and publisher, as they can for `pdfcraft.exe` (`apps/pdfcraft/build.rs`). +//! +//! On every other target this does nothing. A missing resource compiler is a warning, so a +//! cross-compile from macOS or Linux still links, unless `PDFCRAFT_REQUIRE_WINRES=1` turns it +//! into an error (for release builds). + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-env-changed=PDFCRAFT_REQUIRE_WINRES"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let mut res = winresource::WindowsResource::new(); + res.set("ProductName", "PdfCraft") + .set("FileDescription", "PdfCraft command-line tool") + .set("LegalCopyright", "Copyright (c) the PdfCraft contributors. MIT OR Apache-2.0.") + .set("OriginalFilename", "pdfcraft-cli.exe") + .set("InternalName", "pdfcraft-cli"); + if let Err(e) = res.compile() { + if std::env::var_os("PDFCRAFT_REQUIRE_WINRES").is_some() { + println!("cargo::error=embedding Windows resources failed: {e}"); + return; + } + println!("cargo:warning=pdfcraft-cli.exe built without version resources: {e}"); + } +} diff --git a/apps/pdfcraft-cli/tests/version_resource.rs b/apps/pdfcraft-cli/tests/version_resource.rs new file mode 100644 index 0000000000..b680a32ad5 --- /dev/null +++ b/apps/pdfcraft-cli/tests/version_resource.rs @@ -0,0 +1,54 @@ +//! `pdfcraft-cli.exe` carries a Windows version resource (`build.rs`), so Explorer, PowerShell's +//! `VersionInfo` and inventory tools see its version and publisher, as they do for `pdfcraft.exe`. + +#![cfg(windows)] + +/// `s` as UTF-16LE, the encoding of every string in a version resource. +fn utf16(s: &str) -> Vec { + s.encode_utf16().flat_map(u16::to_le_bytes).collect() +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +/// A little-endian 32-bit DWORD at `at`. +fn dword(bytes: &[u8], at: usize) -> u32 { + let b: [u8; 4] = bytes[at..at + 4].try_into().unwrap(); + u32::from_le_bytes(b) +} + +/// The value of string-table entry `key`: the key and its terminator, zero padding to the next +/// 32-bit boundary, then the value and its terminator. +fn string_value(resource: &[u8], key: &str) -> Option { + let after_key = find(resource, &utf16(&format!("{key}\0")))? + utf16(key).len() + 2; + let start = after_key + resource[after_key..].iter().take_while(|&&b| b == 0).count(); + let units: Vec = resource[start..].as_chunks::<2>().0.iter().map(|&c| u16::from_le_bytes(c)).take_while(|&u| u != 0).collect(); + String::from_utf16(&units).ok() +} + +#[test] +fn the_exe_has_version_info_with_the_package_version() { + let exe = std::fs::read(env!("CARGO_BIN_EXE_pdfcraft-cli")).unwrap(); + let start = find(&exe, &utf16("VS_VERSION_INFO")).expect("no version resource"); + let resource = &exe[start..]; + for (key, value) in [ + ("ProductName", "PdfCraft"), + ("FileDescription", "PdfCraft command-line tool"), + ("LegalCopyright", "Copyright (c) the PdfCraft contributors. MIT OR Apache-2.0."), + ("OriginalFilename", "pdfcraft-cli.exe"), + ("InternalName", "pdfcraft-cli"), + ("FileVersion", env!("CARGO_PKG_VERSION")), + ("ProductVersion", env!("CARGO_PKG_VERSION")), + ] { + assert_eq!(string_value(resource, key).as_deref(), Some(value), "{key}"); + } + // VS_FIXEDFILEINFO: signature, struct version, then file and product versions as + // major << 16 | minor and patch << 16 | build. + let fixed = start + find(resource, &0xFEEF_04BDu32.to_le_bytes()).expect("no VS_FIXEDFILEINFO"); + let number = |s: &str| -> u32 { s.parse().unwrap() }; + let ms = (number(env!("CARGO_PKG_VERSION_MAJOR")) << 16) | number(env!("CARGO_PKG_VERSION_MINOR")); + let ls = number(env!("CARGO_PKG_VERSION_PATCH")) << 16; + assert_eq!([dword(&exe, fixed + 8), dword(&exe, fixed + 12)], [ms, ls], "file version"); + assert_eq!([dword(&exe, fixed + 16), dword(&exe, fixed + 20)], [ms, ls], "product version"); +}