diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 2405193..c1c4d88 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -173,6 +173,9 @@ jobs: components: rustfmt,clippy - name: Build Release Binary run: cargo build --verbose --release --target ${{ matrix.target }} + - name: Build Windows Thumbnail Handler DLL + if: matrix.os == 'windows-latest' + run: cargo build --verbose --release --target ${{ matrix.target }} -p ltk-tex-thumb-handler - name: Upload Release Asset uses: actions/upload-release-asset@v1 env: @@ -182,3 +185,13 @@ jobs: asset_path: ./target/${{ matrix.target }}/release/${{ matrix.artifact_name }} asset_name: ${{ matrix.asset_name }} asset_content_type: application/octet-stream + - name: Upload Windows Thumbnail Handler DLL + if: matrix.os == 'windows-latest' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ./target/${{ matrix.target }}/release/ltk_tex_thumb_handler.dll + asset_name: ltk-tex-thumb-handler.dll + asset_content_type: application/octet-stream diff --git a/Cargo.lock b/Cargo.lock index a359cf6..0550ee2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,15 @@ dependencies = [ "equator", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.20" @@ -245,6 +254,19 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.48" @@ -334,6 +356,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crc32fast" version = "1.5.0" @@ -594,6 +622,30 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "image" version = "0.25.8" @@ -734,6 +786,16 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -792,6 +854,17 @@ dependencies = [ "imgref", ] +[[package]] +name = "ltk-tex-thumb-handler" +version = "0.0.1" +dependencies = [ + "chrono", + "league-toolkit", + "windows", + "windows-core", + "winreg", +] + [[package]] name = "ltk-tex-utils" version = "0.1.4" @@ -1843,12 +1916,85 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "windows-link" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1867,6 +2013,21 @@ dependencies = [ "windows-targets 0.53.4", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1900,6 +2061,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -1912,6 +2079,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -1924,6 +2097,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1948,6 +2127,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -1960,6 +2145,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -1972,6 +2163,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -1984,6 +2181,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2005,6 +2208,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index 4600c43..6c68dbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,8 @@ tracing = "0.1.41" tracing-subscriber = { version = "0.3.2", features = ["local-time"] } image = { version = "0.25.2" } + +[workspace] +members = [ + "crates/*", +] \ No newline at end of file diff --git a/README.md b/README.md index 34df318..ad585e2 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Small CLI utilities for working with League of Legends TEX textures, powered by - **Encode**: convert standard images (PNG/JPG/TGA/BMP/…) into `.tex` - **Decode**: convert `.tex` back to common image formats (driven by output file extension) - **Mipmaps**: optional generation with selectable filters +- **Windows Thumbnail Provider**: show `.tex` file previews directly in Windows Explorer ### Install @@ -25,21 +26,30 @@ iwr -useb https://raw.githubusercontent.com/LeagueToolkit/ltk-tex-utils/main/scr This downloads the latest release, installs it to `%LOCALAPPDATA%\LeagueToolkit\ltk-tex-utils`, adds a stable `bin` shim to your user `PATH`, and makes `ltk-tex-utils` available in new terminals. -From source (all platforms): +### Windows Explorer Thumbnail Provider -Prerequisites: Rust (stable) with `cargo`. +On Windows, you can install a thumbnail provider that shows previews of `.tex` files directly in Windows Explorer. -- From a local clone: +**Installation (requires administrator privileges):** -```bash -cargo install --path . +```powershell +# Run PowerShell as Administrator, then: +iwr -useb https://raw.githubusercontent.com/LeagueToolkit/ltk-tex-utils/main/scripts/install-thumbnail-handler.ps1 | iex ``` -Or build locally and use the binary from `target/release`: +This will: +- Download the `ltk-tex-thumb-handler.dll` from the latest release +- Install it to `%ProgramFiles%\LeagueToolkit\ltk-tex-thumb-handler` +- Register the COM DLL with Windows Explorer -```bash -cargo build --release -# Binary: target/release/ltk-tex-utils(.exe) +**Note:** You may need to restart Windows Explorer or your computer for thumbnails to appear. + +**Uninstallation:** + +To uninstall the thumbnail handler, run PowerShell as Administrator and execute: + +```powershell +regsvr32.exe /u "%ProgramFiles%\LeagueToolkit\ltk-tex-thumb-handler\ltk_tex_thumb_handler.dll" ``` ### Usage diff --git a/crates/ltk-tex-thumb-handler/Cargo.toml b/crates/ltk-tex-thumb-handler/Cargo.toml new file mode 100644 index 0000000..96acf41 --- /dev/null +++ b/crates/ltk-tex-thumb-handler/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "ltk-tex-thumb-handler" +version = "0.0.1" +edition = "2024" +publish = false + +[lib] +name = "ltk_tex_thumb_handler" +crate-type = ["cdylib"] + +[package.metadata] +description = "Windows Explorer thumbnail provider COM DLL for LeagueToolkit .tex previews" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58.0", features = [ + "implement", + "Win32_Foundation", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_System_Ole", + "Win32_System_Registry", + "Win32_System_LibraryLoader", + "Win32_UI_Shell", + "Win32_UI_Shell_PropertiesSystem", + "Win32_Graphics_Gdi", +] } +windows-core = "0.58.0" +winreg = { version = "0.52", optional = false } +chrono = "0.4" +league-toolkit = { git = "https://github.com/LeagueToolkit/league-toolkit", rev = "d44b64f7a484081473a3e4a71a7e96d5ca0f0615", features = [ + "texture", + "file", +] } diff --git a/crates/ltk-tex-thumb-handler/src/class_factory.rs b/crates/ltk-tex-thumb-handler/src/class_factory.rs new file mode 100644 index 0000000..fbc907d --- /dev/null +++ b/crates/ltk-tex-thumb-handler/src/class_factory.rs @@ -0,0 +1,99 @@ +// ============================================================================= +// CLASS FACTORY (following Microsoft's CClassFactory pattern) +// ============================================================================= + +use std::ffi::c_void; +use std::ptr; +use std::sync::atomic::AtomicI32; +use windows::Win32::Foundation::*; +use windows::Win32::System::Com::*; +use windows::core::*; + +use crate::thumbnail_provider::CTexThumbProvider_CreateInstance; +use crate::{CLSID_TEX_THUMB_HANDLER, DllAddRef, DllRelease}; + +pub type PfnCreateInstance = fn(*const GUID, *mut *mut c_void) -> HRESULT; + +pub struct ClassObjectInit { + pub pClsid: &'static GUID, + pub pfnCreate: PfnCreateInstance, +} + +/// Registry of class objects supported by this DLL +pub const C_RGCLASSOBJECTINIT: &[ClassObjectInit] = &[ClassObjectInit { + pClsid: &CLSID_TEX_THUMB_HANDLER, + pfnCreate: CTexThumbProvider_CreateInstance, +}]; + +#[implement(IClassFactory)] +pub struct CClassFactory { + #[allow(dead_code)] + cRef: AtomicI32, + pfnCreate: PfnCreateInstance, +} + +impl CClassFactory { + fn new(pfnCreate: PfnCreateInstance) -> Self { + DllAddRef(); + Self { + cRef: AtomicI32::new(1), + pfnCreate, + } + } + + pub fn create_instance( + clsid: &GUID, + class_inits: &[ClassObjectInit], + riid: *const GUID, + ppv: *mut *mut c_void, + ) -> HRESULT { + unsafe { + *ppv = ptr::null_mut(); + } + + // Find matching CLSID + for init in class_inits { + if clsid == init.pClsid { + let factory = CClassFactory::new(init.pfnCreate); + let unknown: IUnknown = factory.into(); + return unsafe { unknown.query(riid, ppv) }; + } + } + + CLASS_E_CLASSNOTAVAILABLE + } +} + +impl IClassFactory_Impl for CClassFactory_Impl { + fn CreateInstance( + &self, + punkOuter: Option<&IUnknown>, + riid: *const GUID, + ppvObject: *mut *mut c_void, + ) -> Result<()> { + if punkOuter.is_some() { + return Err(Error::from(CLASS_E_NOAGGREGATION)); + } + let hr = (self.pfnCreate)(riid, ppvObject); + if hr.is_ok() { + Ok(()) + } else { + Err(Error::from(hr)) + } + } + + fn LockServer(&self, fLock: BOOL) -> Result<()> { + if fLock.as_bool() { + DllAddRef(); + } else { + DllRelease(); + } + Ok(()) + } +} + +impl Drop for CClassFactory { + fn drop(&mut self) { + DllRelease(); + } +} diff --git a/crates/ltk-tex-thumb-handler/src/debug.rs b/crates/ltk-tex-thumb-handler/src/debug.rs new file mode 100644 index 0000000..69a16ad --- /dev/null +++ b/crates/ltk-tex-thumb-handler/src/debug.rs @@ -0,0 +1,21 @@ +// ============================================================================= +// DEBUG LOGGING +// ============================================================================= + +use std::io::Write; + +#[allow(dead_code)] +pub fn debug_log(msg: &str) { + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("C:\\temp\\ltk_tex_thumb_debug.log") + { + let _ = writeln!( + file, + "[{}] {}", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), + msg + ); + } +} diff --git a/crates/ltk-tex-thumb-handler/src/image_processing.rs b/crates/ltk-tex-thumb-handler/src/image_processing.rs new file mode 100644 index 0000000..d8002da --- /dev/null +++ b/crates/ltk-tex-thumb-handler/src/image_processing.rs @@ -0,0 +1,114 @@ +// ============================================================================= +// IMAGE PROCESSING AND BITMAP OPERATIONS +// ============================================================================= + +use league_toolkit::texture::Tex; +use std::ffi::c_void; +use std::io::Cursor; +use std::{mem, ptr}; +use windows::Win32::Foundation::*; +use windows::Win32::Graphics::Gdi::*; +use windows::Win32::System::Com::IStream; +use windows::core::*; + +/// Read all bytes from IStream +pub unsafe fn read_stream_to_bytes(stream: &IStream) -> Result> { + let mut buf = Vec::with_capacity(64 * 1024); + loop { + let mut chunk = [0u8; 64 * 1024]; + let mut read = 0u32; + let hr = unsafe { + stream.Read( + chunk.as_mut_ptr() as *mut c_void, + chunk.len() as u32, + Some(&mut read), + ) + }; + if !hr.is_ok() { + return Err(Error::from(hr)); + } + if read == 0 { + break; + } + buf.extend_from_slice(&chunk[..read as usize]); + } + Ok(buf) +} + +/// Decode TEX file to RGBA image data +pub fn decode_tex_file(bytes: &[u8]) -> Result<(Vec, u32, u32)> { + let mut cursor = Cursor::new(bytes); + let tex = Tex::from_reader(&mut cursor).map_err(|_| Error::from(E_FAIL))?; + let image = tex.decode_mipmap(0).map_err(|_| Error::from(E_FAIL))?; + let rgba = image.into_rgba_image().map_err(|_| Error::from(E_FAIL))?; + let width = rgba.width(); + let height = rgba.height(); + let data = rgba.into_raw(); + Ok((data, width, height)) +} + +/// Scale RGBA image to fit within thumbnail size +pub fn scale_image(src: &[u8], src_w: u32, src_h: u32, cx: u32) -> (Vec, u32, u32) { + let (dst_w, dst_h) = if src_w >= src_h { + (cx, (src_h * cx + src_w / 2) / src_w) + } else { + ((src_w * cx + src_h / 2) / src_h, cx) + }; + + let mut out = vec![0u8; (dst_w * dst_h * 4) as usize]; + for y in 0..dst_h { + let sy = (y * src_h / dst_h).clamp(0, src_h - 1); + for x in 0..dst_w { + let sx = (x * src_w / dst_w).clamp(0, src_w - 1); + let si = ((sy * src_w + sx) * 4) as usize; + let di = ((y * dst_w + x) * 4) as usize; + out[di..di + 4].copy_from_slice(&src[si..si + 4]); + } + } + (out, dst_w, dst_h) +} + +/// Convert RGBA to premultiplied BGRA HBITMAP (following Microsoft's ConvertBitmapSourceTo32BPPHBITMAP pattern) +pub unsafe fn create_hbitmap_from_rgba(rgba: &[u8], width: u32, height: u32) -> Result { + let mut bi: BITMAPV5HEADER = unsafe { mem::zeroed() }; + bi.bV5Size = mem::size_of::() as u32; + bi.bV5Width = width as i32; + bi.bV5Height = -(height as i32); // Top-down DIB + bi.bV5Planes = 1; + bi.bV5BitCount = 32; + bi.bV5Compression = BI_RGB; // Use standard RGB format (which is actually BGRA in memory) + + let mut bits: *mut c_void = ptr::null_mut(); + let hbmp = unsafe { + CreateDIBSection( + HDC(std::ptr::null_mut()), + &bi as *const _ as *const BITMAPINFO, + DIB_RGB_COLORS, + &mut bits, + None, + 0, + )? + }; + + if hbmp.is_invalid() || bits.is_null() { + return Err(Error::from(E_FAIL)); + } + + let dst = + unsafe { std::slice::from_raw_parts_mut(bits as *mut u8, (width * height * 4) as usize) }; + + for i in 0..(width * height) as usize { + let r = rgba[i * 4]; + let g = rgba[i * 4 + 1]; + let b = rgba[i * 4 + 2]; + let a = rgba[i * 4 + 3]; + + // Write as BGRA for Windows (just swap R and B, no premultiplication) + dst[i * 4] = b; + dst[i * 4 + 1] = g; + dst[i * 4 + 2] = r; + dst[i * 4 + 3] = a; + } + + Ok(hbmp) +} diff --git a/crates/ltk-tex-thumb-handler/src/lib.rs b/crates/ltk-tex-thumb-handler/src/lib.rs new file mode 100644 index 0000000..8e67347 --- /dev/null +++ b/crates/ltk-tex-thumb-handler/src/lib.rs @@ -0,0 +1,125 @@ +// THIS CODE IS BASED ON MICROSOFT'S RECIPETHUMBNAILPROVIDER SAMPLE +// Adapted for LTK TEX file thumbnail generation +// +// Original Microsoft sample: +// https://github.com/microsoft/Windows-classic-samples/tree/main/Samples/Win7Samples/winui/shell/appshellintegration/RecipeThumbnailProvider + +#![cfg(windows)] +#![allow(non_snake_case)] + +use std::ffi::c_void; +use std::sync::atomic::{AtomicI32, Ordering}; +use windows::Win32::Foundation::*; +use windows::core::*; + +mod class_factory; +mod debug; +mod image_processing; +mod registration; +mod thumbnail_provider; + +// Re-export for internal use +use class_factory::{C_RGCLASSOBJECTINIT, CClassFactory}; +use registration::{register_server, unregister_server}; + +// ============================================================================= +// CONSTANTS AND GLOBALS +// ============================================================================= + +/// CLSID for the TEX Thumbnail Handler +/// {2f7e3e47-3b6b-4d59-9d42-4f4b0a5ba1b9} +pub const CLSID_TEX_THUMB_HANDLER: GUID = GUID::from_u128(0x2f7e3e47_3b6b_4d59_9d42_4f4b0a5ba1b9); + +/// Module reference count for DLL lifetime management +static G_CREF_MODULE: AtomicI32 = AtomicI32::new(0); + +/// DLL module handle +#[allow(dead_code)] +static mut G_HINST: HINSTANCE = HINSTANCE(std::ptr::null_mut()); + +// ============================================================================= +// MODULE REFERENCE COUNTING (following Microsoft pattern) +// ============================================================================= + +pub(crate) fn DllAddRef() { + G_CREF_MODULE.fetch_add(1, Ordering::SeqCst); +} + +pub(crate) fn DllRelease() { + G_CREF_MODULE.fetch_sub(1, Ordering::SeqCst); +} + +// ============================================================================= +// STANDARD DLL EXPORTS (following Microsoft Dll.cpp pattern) +// ============================================================================= + +/// Standard DLL entry point +#[unsafe(no_mangle)] +unsafe extern "system" fn DllMain( + hinstance: HINSTANCE, + reason: u32, + _reserved: *mut c_void, +) -> BOOL { + if reason == 1 { + // DLL_PROCESS_ATTACH + unsafe { + G_HINST = hinstance; + } + } + TRUE +} + +/// Returns S_OK if DLL can be unloaded, S_FALSE otherwise +/// +/// # Safety +/// This function is safe to call. It only reads an atomic counter. +#[unsafe(no_mangle)] +pub unsafe extern "system" fn DllCanUnloadNow() -> HRESULT { + if G_CREF_MODULE.load(Ordering::SeqCst) == 0 { + S_OK + } else { + S_FALSE + } +} + +/// Creates a class factory for the requested CLSID +/// +/// # Safety +/// The caller must ensure that `rclsid` and `riid` point to valid GUIDs, +/// and `ppv` points to a valid pointer location. +#[unsafe(no_mangle)] +pub unsafe extern "system" fn DllGetClassObject( + rclsid: *const GUID, + riid: *const GUID, + ppv: *mut *mut c_void, +) -> HRESULT { + if rclsid.is_null() { + return E_INVALIDARG; + } + + unsafe { CClassFactory::create_instance(&*rclsid, C_RGCLASSOBJECTINIT, riid, ppv) } +} + +/// Registers this COM server (following Microsoft's DllRegisterServer pattern) +/// +/// # Safety +/// This function modifies the Windows registry and requires administrative privileges. +#[unsafe(no_mangle)] +pub unsafe extern "system" fn DllRegisterServer() -> HRESULT { + match unsafe { register_server(DllRegisterServer as *const u16) } { + Ok(()) => S_OK, + Err(e) => e.into(), + } +} + +/// Unregisters this COM server (following Microsoft's DllUnregisterServer pattern) +/// +/// # Safety +/// This function modifies the Windows registry and requires administrative privileges. +#[unsafe(no_mangle)] +pub unsafe extern "system" fn DllUnregisterServer() -> HRESULT { + match unsafe { unregister_server() } { + Ok(()) => S_OK, + Err(e) => e.into(), + } +} diff --git a/crates/ltk-tex-thumb-handler/src/registration.rs b/crates/ltk-tex-thumb-handler/src/registration.rs new file mode 100644 index 0000000..05ee2e8 --- /dev/null +++ b/crates/ltk-tex-thumb-handler/src/registration.rs @@ -0,0 +1,148 @@ +// ============================================================================= +// REGISTRATION HELPERS (following Microsoft registry patterns) +// ============================================================================= + +use windows::Win32::Foundation::{HMODULE, *}; +use windows::Win32::System::LibraryLoader::{ + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GetModuleFileNameW, GetModuleHandleExW, +}; +use windows::core::*; +use winreg::HKEY; +use winreg::RegKey; +use winreg::enums::{HKEY_CLASSES_ROOT, HKEY_LOCAL_MACHINE, KEY_WRITE}; + +pub const SZ_CLSID_TEXTHUMBHANDLER: &str = "{2f7e3e47-3b6b-4d59-9d42-4f4b0a5ba1b9}"; +pub const SZ_TEXTHUMBHANDLER: &str = "LTK TEX Thumbnail Handler"; + +pub struct RegistryEntry { + pub hkeyRoot: HKEY, + pub pszKeyName: String, + pub pszValueName: Option, + pub pszData: String, +} + +impl RegistryEntry { + pub fn new( + root: HKEY, + key: impl Into, + value: Option>, + data: impl Into, + ) -> Self { + Self { + hkeyRoot: root, + pszKeyName: key.into(), + pszValueName: value.map(|v| v.into()), + pszData: data.into(), + } + } +} + +/// Creates a registry key and sets its value (following Microsoft's CreateRegKeyAndSetValue pattern) +fn create_reg_key_and_set_value(entry: &RegistryEntry) -> Result<()> { + let root = RegKey::predef(entry.hkeyRoot); + let (key, _) = root + .create_subkey(&entry.pszKeyName) + .map_err(|_| Error::from(E_FAIL))?; + + let value_name = entry.pszValueName.as_deref().unwrap_or(""); + key.set_value(value_name, &entry.pszData) + .map_err(|_| Error::from(E_FAIL))?; + + Ok(()) +} + +/// Register COM server and shell extension (following Microsoft pattern) +pub unsafe fn register_server(dll_register_server_fn: *const u16) -> Result<()> { + // Get DLL path + let mut hmodule = HMODULE(std::ptr::null_mut()); + unsafe { + GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + PCWSTR(dll_register_server_fn), + &mut hmodule, + )? + }; + + let mut buf = [0u16; 32768]; + let len = unsafe { GetModuleFileNameW(hmodule, &mut buf) }; + if len == 0 { + return Err(Error::from(E_FAIL)); + } + let dll_path = String::from_utf16_lossy(&buf[..len as usize]); + + let entries = vec![ + RegistryEntry::new( + HKEY_CLASSES_ROOT, + format!("CLSID\\{}", SZ_CLSID_TEXTHUMBHANDLER), + None::, + SZ_TEXTHUMBHANDLER, + ), + RegistryEntry::new( + HKEY_CLASSES_ROOT, + format!("CLSID\\{}\\InprocServer32", SZ_CLSID_TEXTHUMBHANDLER), + None::, + &dll_path, + ), + RegistryEntry::new( + HKEY_CLASSES_ROOT, + format!("CLSID\\{}\\InprocServer32", SZ_CLSID_TEXTHUMBHANDLER), + Some("ThreadingModel"), + "Apartment", + ), + // .tex file association with IThumbnailProvider + RegistryEntry::new( + HKEY_CLASSES_ROOT, + ".tex\\ShellEx\\{e357fccd-a995-4576-b01f-234630154e96}", + None::, + SZ_CLSID_TEXTHUMBHANDLER, + ), + ]; + + // Register all entries + for entry in &entries { + create_reg_key_and_set_value(entry)?; + } + + // Approve shell extension (Windows requirement) + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let (approved, _) = hklm + .create_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Shell Extensions\\Approved") + .map_err(|_| Error::from(E_FAIL))?; + + approved + .set_value(SZ_CLSID_TEXTHUMBHANDLER, &SZ_TEXTHUMBHANDLER) + .map_err(|_| Error::from(E_FAIL))?; + + // Disable process isolation for better debugging + let hkcr = RegKey::predef(HKEY_CLASSES_ROOT); + if let Ok(clsid_key) = + hkcr.open_subkey_with_flags(format!("CLSID\\{}", SZ_CLSID_TEXTHUMBHANDLER), KEY_WRITE) + { + let _ = clsid_key.set_value("DisableProcessIsolation", &1u32); + } + + // Notify shell of changes (following Microsoft pattern) + use windows::Win32::UI::Shell::SHChangeNotify; + use windows::Win32::UI::Shell::{SHCNE_ASSOCCHANGED, SHCNF_IDLIST}; + unsafe { SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None) }; + + Ok(()) +} + +/// Unregister COM server and shell extension (following Microsoft pattern) +pub unsafe fn unregister_server() -> Result<()> { + let hkcr = RegKey::predef(HKEY_CLASSES_ROOT); + + let _ = hkcr.delete_subkey_all(format!("CLSID\\{}", SZ_CLSID_TEXTHUMBHANDLER)); + let _ = hkcr.delete_subkey_all(".tex\\ShellEx\\{e357fccd-a995-4576-b01f-234630154e96}"); + + // Remove from approved extensions + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + if let Ok(approved) = + hklm.open_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Shell Extensions\\Approved") + { + let _ = approved.delete_value(SZ_CLSID_TEXTHUMBHANDLER); + } + + Ok(()) +} diff --git a/crates/ltk-tex-thumb-handler/src/thumbnail_provider.rs b/crates/ltk-tex-thumb-handler/src/thumbnail_provider.rs new file mode 100644 index 0000000..28ef7cf --- /dev/null +++ b/crates/ltk-tex-thumb-handler/src/thumbnail_provider.rs @@ -0,0 +1,88 @@ +// ============================================================================= +// TEX THUMBNAIL PROVIDER (following Microsoft's CRecipeThumbProvider pattern) +// ============================================================================= + +use std::ffi::c_void; +use std::sync::atomic::AtomicI32; +use windows::Win32::Foundation::*; +use windows::Win32::Graphics::Gdi::HBITMAP; +use windows::Win32::System::Com::IStream; +use windows::Win32::UI::Shell::PropertiesSystem::{ + IInitializeWithStream, IInitializeWithStream_Impl, +}; +use windows::Win32::UI::Shell::{ + IThumbnailProvider, IThumbnailProvider_Impl, WTS_ALPHATYPE, WTSAT_RGB, +}; +use windows::core::*; + +use crate::image_processing::*; + +#[implement(IInitializeWithStream, IThumbnailProvider)] +pub struct CTexThumbProvider { + #[allow(dead_code)] + cRef: AtomicI32, + pStream: std::sync::Mutex>, +} + +impl Default for CTexThumbProvider { + fn default() -> Self { + Self::new() + } +} + +impl CTexThumbProvider { + pub fn new() -> Self { + Self { + cRef: AtomicI32::new(1), + pStream: std::sync::Mutex::new(None), + } + } +} + +impl IInitializeWithStream_Impl for CTexThumbProvider_Impl { + fn Initialize(&self, pStream: Option<&IStream>, _grfMode: u32) -> Result<()> { + // Can only be initialized once + let mut stream_guard = self.pStream.lock().unwrap(); + if stream_guard.is_some() { + return Err(Error::from(E_UNEXPECTED)); + } + + // Take a reference to the stream + if let Some(stream) = pStream { + *stream_guard = Some(stream.clone()); + Ok(()) + } else { + Err(Error::from(E_INVALIDARG)) + } + } +} + +impl IThumbnailProvider_Impl for CTexThumbProvider_Impl { + fn GetThumbnail( + &self, + cx: u32, + phbmp: *mut HBITMAP, + pdwAlpha: *mut WTS_ALPHATYPE, + ) -> Result<()> { + let stream_guard = self.pStream.lock().unwrap(); + let stream = stream_guard.as_ref().ok_or(Error::from(E_UNEXPECTED))?; + + let bytes = unsafe { read_stream_to_bytes(stream)? }; + let (rgba, width, height) = decode_tex_file(&bytes)?; + let (scaled_rgba, scaled_w, scaled_h) = scale_image(&rgba, width, height, cx); + let hbmp = unsafe { create_hbitmap_from_rgba(&scaled_rgba, scaled_w, scaled_h)? }; + + unsafe { + *phbmp = hbmp; + *pdwAlpha = WTSAT_RGB; // Not using premultiplied alpha + } + + Ok(()) + } +} + +pub fn CTexThumbProvider_CreateInstance(riid: *const GUID, ppv: *mut *mut c_void) -> HRESULT { + let provider = CTexThumbProvider::new(); + let unknown: IUnknown = provider.into(); + unsafe { unknown.query(riid, ppv) } +} diff --git a/scripts/install-thumbnail-handler.ps1 b/scripts/install-thumbnail-handler.ps1 new file mode 100644 index 0000000..0929da1 --- /dev/null +++ b/scripts/install-thumbnail-handler.ps1 @@ -0,0 +1,77 @@ +#Requires -RunAsAdministrator + +param( + [string]$Owner = "LeagueToolkit", + [string]$Repo = "ltk-tex-utils", + [string]$InstallDir = "$env:ProgramFiles\LeagueToolkit\ltk-tex-thumb-handler" +) + +$ErrorActionPreference = 'Stop' + +Write-Host "Installing ltk-tex-thumb-handler (Windows Explorer thumbnail provider)..." -ForegroundColor Cyan +Write-Host "This script requires administrator privileges to register the COM DLL." -ForegroundColor Yellow + +if (!(Test-Path -LiteralPath $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +} + +# Get latest release metadata +$releaseApi = "https://api.github.com/repos/$Owner/$Repo/releases/latest" +try { + $release = Invoke-RestMethod -Uri $releaseApi -Headers @{ 'User-Agent' = 'ltk-tex-utils-installer' } +} catch { + throw "Failed to query GitHub releases: $($_.Exception.Message)" +} + +$tag = $release.tag_name +# Extract the first semantic version (handles tags like "v0.1.1") +$match = [regex]::Match($tag, '\d+\.\d+\.\d+([\-\+][A-Za-z0-9\.-]+)?') +$version = if ($match.Success) { $match.Value } else { $tag.TrimStart('v') } + +# Find the thumbnail handler DLL asset +$assetName = "ltk-tex-thumb-handler.dll" +$asset = $release.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1 +if (-not $asset) { + throw "Could not find $assetName in the latest release. Make sure you're using a release that includes the thumbnail handler." +} + +$dllPath = Join-Path $InstallDir 'ltk_tex_thumb_handler.dll' +$tmpPath = Join-Path $env:TEMP $assetName + +Write-Host "Downloading $assetName ($version)..." -ForegroundColor Yellow +Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $tmpPath -UseBasicParsing + +# Unregister old DLL if it exists +if (Test-Path -LiteralPath $dllPath) { + Write-Host "Unregistering existing DLL..." -ForegroundColor Yellow + try { + & regsvr32.exe /s /u $dllPath + } catch { + Write-Warning "Failed to unregister old DLL: $($_.Exception.Message)" + } +} + +Write-Host "Installing DLL to $InstallDir" -ForegroundColor Yellow +Copy-Item -LiteralPath $tmpPath -Destination $dllPath -Force + +# Ensure the DLL exists +if (!(Test-Path -LiteralPath $dllPath)) { + throw "ltk_tex_thumb_handler.dll not found after download: $dllPath" +} + +# Register the COM DLL +Write-Host "Registering COM DLL with Windows..." -ForegroundColor Yellow +$regResult = & regsvr32.exe /s $dllPath +if ($LASTEXITCODE -ne 0) { + throw "Failed to register DLL. regsvr32 returned exit code: $LASTEXITCODE" +} + +Write-Host "Successfully installed and registered ltk-tex-thumb-handler $version" -ForegroundColor Green +Write-Host "Windows Explorer will now show thumbnails for .tex files." -ForegroundColor Cyan +Write-Host "" +Write-Host "Note: You may need to restart Windows Explorer or your computer for thumbnails to appear." -ForegroundColor Yellow +Write-Host "To restart Explorer: Task Manager > Windows Explorer > Restart" -ForegroundColor Gray + +# Clean up temp file +Remove-Item -LiteralPath $tmpPath -Force -ErrorAction SilentlyContinue +