From dec9f3600bec49cb783917fbdc2797ac36d3d14c Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:23:38 +0100 Subject: [PATCH] Windows: Tesseract and the --control tools start without a console window (#6735) --- crates/ocr/src/tesseract.rs | 33 ++++++++++++++++++++++++++++++- crates/platform/src/owner_only.rs | 33 ++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/ocr/src/tesseract.rs b/crates/ocr/src/tesseract.rs index 9fbcc574d8..a4e1407331 100644 --- a/crates/ocr/src/tesseract.rs +++ b/crates/ocr/src/tesseract.rs @@ -343,8 +343,17 @@ impl TesseractCli { // Known limit: only the direct child is killed at the timeout or on an early cap. A // wrapper script's children (grandchildren of this process) may survive the kill; the // cap and the timeout still bound what this run reads and how long it waits. + let mut command = std::process::Command::new(&self.program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + // `CREATE_NO_WINDOW`: tesseract is a console program and the app is not, so without + // it Windows opens a console window for every run, which flashes up during OCR. The + // output files above are unaffected. + command.creation_flags(0x0800_0000); + } let mut child = - std::process::Command::new(&self.program) + command .args(args) .stdin(std::process::Stdio::null()) .stdout(out_file) @@ -894,6 +903,28 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The program starts without a console window (Windows): the app has no console, so a + /// console program that isn't told otherwise gets a window of its own, which flashes up on + /// every OCR run. PowerShell stands in for tesseract and reports, through the same output file + /// a reading comes back in, its console's window and the processes attached to that console. + /// Both are checked, because a program started without the flag by a process that has a + /// console shares it instead, and that console may itself have no window. + #[cfg(windows)] + #[test] + fn the_program_starts_without_a_console_window() { + const PROBE: &str = r#"Add-Type -Namespace PdfCraftOcrProbe -Name Console -MemberDefinition '[DllImport("kernel32.dll")] public static extern System.IntPtr GetConsoleWindow(); [DllImport("kernel32.dll")] public static extern uint GetConsoleProcessList(uint[] ids, uint count);' +$ids = New-Object uint32[] 256 +$n = [PdfCraftOcrProbe.Console]::GetConsoleProcessList($ids, 256) +"$([PdfCraftOcrProbe.Console]::GetConsoleWindow()) $($ids[0..($n-1)] -join ',')""#; + let root = std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from); + let t = TesseractCli::new(root.join(r"System32\WindowsPowerShell\v1.0\powershell.exe")); + let text = t.run(&["-NoProfile", "-NonInteractive", "-Command", PROBE], t.timeout, t.max_output).unwrap(); + let (window, attached) = text.trim().split_once(' ').unwrap(); + assert_eq!(window, "0", "the program has a console window: {text}"); + let attached: Vec = attached.split(',').map(|id| id.parse().unwrap()).collect(); + assert!(!attached.is_empty() && !attached.contains(&std::process::id()), "the program shares this process's console: {text}"); + } + #[cfg(unix)] fn make_executable(p: &Path) { use std::os::unix::fs::PermissionsExt; diff --git a/crates/platform/src/owner_only.rs b/crates/platform/src/owner_only.rs index f7f4c20b5d..1151aba5d7 100644 --- a/crates/platform/src/owner_only.rs +++ b/crates/platform/src/owner_only.rs @@ -132,10 +132,19 @@ const ICACLS: &str = "icacls.exe"; /// A tool Windows ships, started by its full path (review on #3956). Started by bare name, a /// `whoami.exe`, `powershell.exe` or `icacls.exe` found first (next to the app, or earlier on the /// search path) would answer instead, and `whoami`'s answer is what every check here trusts. +/// +/// Each tool is a console program and the app is not, so without `CREATE_NO_WINDOW` Windows gives +/// every one of them a console window of its own, which flashes up while the app writes its +/// `--control` file. The flag doesn't touch the pipes `output()` reads the answer through. #[cfg(windows)] fn system_tool(relative: &str) -> std::process::Command { + use std::os::windows::process::CommandExt as _; + /// `CREATE_NO_WINDOW`, from the Windows process creation flags. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; let root = std::env::var_os("SystemRoot").map_or_else(|| std::path::PathBuf::from(r"C:\Windows"), std::path::PathBuf::from); - std::process::Command::new(root.join("System32").join(relative)) + let mut command = std::process::Command::new(root.join("System32").join(relative)); + command.creation_flags(CREATE_NO_WINDOW); + command } #[cfg(windows)] @@ -384,6 +393,28 @@ mod tests { } } + /// The tools start without a console window: the app has no console, so a tool that isn't + /// told otherwise gets a window of its own, which flashes up. The started PowerShell reports + /// its console's window and the processes attached to that console. Both are checked, because + /// a tool started without the flag by a process that has a console shares it instead, and that + /// console may itself have no window (a test run started by a service or an IDE). + #[cfg(windows)] + #[test] + fn the_tools_start_without_a_console_window() { + const PROBE: &str = r#"Add-Type -Namespace PdfCraftProbe -Name Console -MemberDefinition '[DllImport("kernel32.dll")] public static extern System.IntPtr GetConsoleWindow(); [DllImport("kernel32.dll")] public static extern uint GetConsoleProcessList(uint[] ids, uint count);' +$ids = New-Object uint32[] 256 +$n = [PdfCraftProbe.Console]::GetConsoleProcessList($ids, 256) +"$([PdfCraftProbe.Console]::GetConsoleWindow()) $($ids[0..($n-1)] -join ',')""#; + let out = system_tool(POWERSHELL).args(["-NoProfile", "-NonInteractive", "-Command", PROBE]).output().unwrap(); + // The answer still comes back through the pipe. + let text = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{text} {}", String::from_utf8_lossy(&out.stderr)); + let (window, attached) = text.trim().split_once(' ').unwrap(); + assert_eq!(window, "0", "the tool has a console window: {text}"); + let attached: Vec = attached.split(',').map(|id| id.parse().unwrap()).collect(); + assert!(!attached.is_empty() && !attached.contains(&std::process::id()), "the tool shares this process's console: {text}"); + } + #[test] fn other_owners_readers_and_missing_lists_are_refused() { let other = "S-1-5-21-1-2-3-1002";