Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 207 additions & 1 deletion libs/cua-driver-rs/crates/platform-windows/src/tools/impl_.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,96 @@ fn split_launchable_target(s: &str) -> (String, String) {
(trimmed.to_owned(), String::new())
}

/// Returns `true` when the launch target names a Chromium-based browser
/// — i.e. one whose hidden launch under `SW_SHOWNOACTIVATE` will trigger
/// renderer-occlusion throttling unless we inject the anti-throttling flags
/// below. See #1620.
///
/// Matches the executable basename (case-insensitive, with or without
/// `.exe`). Accepts bare names (the App Paths shortcut, e.g. `"msedge"`),
/// full paths (`"C:\...\msedge.exe"`), and round-tripped launch paths with
/// trailing arguments (`"\"C:\...\chrome.exe\" --foo"` — the first token is
/// matched via `split_launchable_target`).
fn is_chromium_browser_target(target: &str) -> bool {
let (file, _) = split_launchable_target(target);
let candidate = if file.is_empty() { target } else { file.as_str() };
let lower = candidate.to_ascii_lowercase();
let basename = lower
.rsplit_once('\\')
.map(|(_, b)| b)
.or_else(|| lower.rsplit_once('/').map(|(_, b)| b))
.unwrap_or(lower.as_str());
let stem = basename.strip_suffix(".exe").unwrap_or(basename);
matches!(
stem,
"msedge"
| "chrome"
| "brave"
| "opera"
| "vivaldi"
| "chromium"
| "thorium"
| "iridium"
| "browser" // Yandex Browser's exe is browser.exe
| "arc"
)
}

/// Anti-throttling flags injected on hidden Chromium launches (#1620).
///
/// `launch_app` uses `SW_SHOWNOACTIVATE` so the launched window is
/// non-foreground from birth. Chromium's `CalculateNativeWinOcclusion`
/// feature treats that as occluded and suspends the renderer process for
/// the tab's entire lifetime — UIA tree exposes only browser chrome, no
/// page DOM, and `PrintWindow` returns a blank body.
///
/// `CalculateNativeWinOcclusion` is the root cause; the two
/// `--disable-backgrounding-*` flags backstop the same effect through the
/// process-priority and renderer-throttling layers (Chromium suspends
/// renderers on multiple signals).
const CHROMIUM_ANTI_THROTTLING_FLAGS: &[&str] = &[
"--disable-features=CalculateNativeWinOcclusion",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
];

/// Prepend the Chromium anti-throttling flags to `extra_args` unless the
/// caller already has each one. Merges into an existing `--disable-features=`
/// list rather than appending a second `--disable-features=` entry (Chromium
/// has subtle merging rules across duplicate flags).
fn inject_chromium_anti_throttling_flags(extra_args: &mut Vec<String>) {
const OCCLUSION_FEATURE: &str = "CalculateNativeWinOcclusion";

// Merge `CalculateNativeWinOcclusion` into any existing
// `--disable-features=` entry, or insert a fresh one at the front.
let has_occlusion = extra_args.iter().any(|a| {
a.strip_prefix("--disable-features=")
.map(|v| v.split(',').any(|f| f.trim() == OCCLUSION_FEATURE))
.unwrap_or(false)
});
if !has_occlusion {
if let Some(idx) = extra_args
.iter()
.position(|a| a.starts_with("--disable-features="))
{
extra_args[idx] = format!("{},{OCCLUSION_FEATURE}", extra_args[idx]);
} else {
extra_args.insert(
0,
format!("--disable-features={OCCLUSION_FEATURE}"),
);
}
}

// Boolean toggles — insert at front only when not already present.
for flag in &["--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding"] {
if !extra_args.iter().any(|a| a == flag) {
extra_args.insert(0, (*flag).to_string());
}
}
let _ = CHROMIUM_ANTI_THROTTLING_FLAGS; // referenced for docs alignment
}

// ── launch_app ───────────────────────────────────────────────────────────────

pub struct LaunchAppTool;
Expand Down Expand Up @@ -718,7 +808,7 @@ impl Tool for LaunchAppTool {
let urls: Vec<String> = args.get("urls").and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
.unwrap_or_default();
let extra_args: Vec<String> = args.get("additional_arguments").and_then(|v| v.as_array())
let mut extra_args: Vec<String> = args.get("additional_arguments").and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
.unwrap_or_default();

Expand All @@ -732,6 +822,26 @@ impl Tool for LaunchAppTool {
.or(name_opt.clone())
.or(bundle_id_opt.clone());

// #1620: auto-inject Chromium anti-throttling flags when the resolved
// target names a Chromium-based browser (Edge / Chrome / Brave /
// Vivaldi / Opera / Chromium / Arc / Thorium / Iridium / Yandex).
// launch_app hides the window via SW_SHOWNOACTIVATE; Chromium's
// occlusion logic suspends the renderer in that case, leaving the
// UIA tree empty and PrintWindow capturing a blank body. The three
// flags below defeat that for the lifetime of the launched process
// without affecting normal interactive use.
//
// Skipped when `aumid` or `bundle_id` route through the UWP path
// (packaged Edge is the exception; the desktop Edge channel is what
// ships now and that goes through the ShellExecuteEx path below).
if launch_path_opt.is_some() || path_opt.is_some() || name_opt.is_some() {
if let Some(t) = target.as_deref() {
if is_chromium_browser_target(t) {
inject_chromium_anti_throttling_flags(&mut extra_args);
}
}
}
Comment on lines +837 to +843

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Include non-AUMID bundle_id launches in the Chromium injection path.

Line 837 excludes bundle_id, even though this tool documents non-AUMID bundle_id as a name alias. That means launch_app({ "bundle_id": "chrome" }) still goes through the ShellExecuteEx path without the anti-throttling flags, so hidden Chromium launches via that supported entry point keep the renderer-occlusion behavior this PR is meant to fix.

🔧 Suggested fix
-        if launch_path_opt.is_some() || path_opt.is_some() || name_opt.is_some() {
+        let uses_shell_execute_target = launch_path_opt.is_some()
+            || path_opt.is_some()
+            || name_opt.is_some()
+            || bundle_id_opt
+                .as_deref()
+                .is_some_and(|s| !crate::launch_uwp::is_aumid(s));
+        if uses_shell_execute_target {
             if let Some(t) = target.as_deref() {
                 if is_chromium_browser_target(t) {
                     inject_chromium_anti_throttling_flags(&mut extra_args);
                 }
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if launch_path_opt.is_some() || path_opt.is_some() || name_opt.is_some() {
if let Some(t) = target.as_deref() {
if is_chromium_browser_target(t) {
inject_chromium_anti_throttling_flags(&mut extra_args);
}
}
}
let uses_shell_execute_target = launch_path_opt.is_some()
|| path_opt.is_some()
|| name_opt.is_some()
|| bundle_id_opt
.as_deref()
.is_some_and(|s| !crate::launch_uwp::is_aumid(s));
if uses_shell_execute_target {
if let Some(t) = target.as_deref() {
if is_chromium_browser_target(t) {
inject_chromium_anti_throttling_flags(&mut extra_args);
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver-rs/crates/platform-windows/src/tools/impl_.rs` around lines
837 - 843, The Chromium anti-throttling flags are skipped for launches that
supply a non-AUMID bundle_id because the current condition only looks at
launch_path_opt, path_opt, or name_opt; update the conditional so launches with
a bundle_id also enter the Chromium-injection branch. Specifically, in the block
that checks if launch_path_opt.is_some() || path_opt.is_some() ||
name_opt.is_some(), include the bundle_id option (or ensure name_opt is set from
bundle_id) so that when let Some(t) = target.as_deref() and
is_chromium_browser_target(t) is true you still call
inject_chromium_anti_throttling_flags(&mut extra_args); keep the same
target/is_chromium_browser_target and inject_chromium_anti_throttling_flags
calls but broaden the presence check to include bundle_id.


if target.is_none() && urls.is_empty() {
// Match Swift's wording verbatim.
return ToolResult::error("Provide either bundle_id or name to identify the app to launch.");
Expand Down Expand Up @@ -3710,3 +3820,99 @@ pub fn build_registry() -> ToolRegistry {
r.register_recording_tools();
r
}

#[cfg(test)]
mod chromium_flag_injection_tests {
use super::{inject_chromium_anti_throttling_flags, is_chromium_browser_target};

#[test]
fn detects_bare_browser_names() {
for name in [
"msedge", "chrome", "brave", "opera", "vivaldi",
"chromium", "thorium", "iridium", "browser", "arc",
] {
assert!(is_chromium_browser_target(name), "{name} should match");
assert!(is_chromium_browser_target(&format!("{name}.exe")), "{name}.exe should match");
// Case-insensitive.
assert!(is_chromium_browser_target(&name.to_uppercase()), "uppercase {name} should match");
}
}

#[test]
fn detects_full_paths() {
assert!(is_chromium_browser_target(
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
));
assert!(is_chromium_browser_target(
r"C:\Program Files\Google\Chrome\Application\chrome.exe"
));
// Forward slashes too (some shells write paths that way).
assert!(is_chromium_browser_target(
r"C:/Program Files/Google/Chrome/Application/chrome.exe"
));
}

#[test]
fn detects_launch_path_with_trailing_args() {
// Round-tripped launch_path from list_apps with shortcut arguments.
assert!(is_chromium_browser_target(
r#""C:\Program Files\Google\Chrome\Application\chrome.exe" --profile-directory="Profile 2""#
));
}

#[test]
fn does_not_match_non_chromium_apps() {
for name in ["firefox", "notepad", "explorer", "code", "soffice"] {
assert!(!is_chromium_browser_target(name), "{name} should NOT match");
assert!(!is_chromium_browser_target(&format!("{name}.exe")), "{name}.exe should NOT match");
}
// Empty target.
assert!(!is_chromium_browser_target(""));
}

#[test]
fn injects_three_flags_into_empty_args() {
let mut args: Vec<String> = vec![];
inject_chromium_anti_throttling_flags(&mut args);
assert!(args.contains(&"--disable-features=CalculateNativeWinOcclusion".to_string()));
assert!(args.contains(&"--disable-backgrounding-occluded-windows".to_string()));
assert!(args.contains(&"--disable-renderer-backgrounding".to_string()));
assert_eq!(args.len(), 3);
}

#[test]
fn merges_into_existing_disable_features_list() {
let mut args = vec!["--disable-features=Foo,Bar".to_string()];
inject_chromium_anti_throttling_flags(&mut args);
// Should NOT have two --disable-features= entries.
let dfe: Vec<_> = args.iter().filter(|a| a.starts_with("--disable-features=")).collect();
assert_eq!(dfe.len(), 1);
assert!(dfe[0].contains("CalculateNativeWinOcclusion"));
assert!(dfe[0].contains("Foo"));
assert!(dfe[0].contains("Bar"));
}

#[test]
fn idempotent_when_all_flags_already_present() {
let mut args = vec![
"--disable-features=CalculateNativeWinOcclusion".to_string(),
"--disable-backgrounding-occluded-windows".to_string(),
"--disable-renderer-backgrounding".to_string(),
];
let before = args.clone();
inject_chromium_anti_throttling_flags(&mut args);
assert_eq!(args, before, "must not duplicate flags");
}

#[test]
fn preserves_user_url_argument_after_flags() {
let mut args = vec!["file:///C:/test_page.html".to_string()];
inject_chromium_anti_throttling_flags(&mut args);
// URL must still be present.
assert!(args.iter().any(|a| a == "file:///C:/test_page.html"));
// All three flags now in args.
assert!(args.iter().any(|a| a == "--disable-features=CalculateNativeWinOcclusion"));
assert!(args.iter().any(|a| a == "--disable-backgrounding-occluded-windows"));
assert!(args.iter().any(|a| a == "--disable-renderer-backgrounding"));
}
}
Loading