From 3a6a9c60a8007357529d3e46b959dfc1ed9a4f78 Mon Sep 17 00:00:00 2001 From: outdog-hwh <2433653167@qq.com> Date: Fri, 26 Jun 2026 09:37:15 +0800 Subject: [PATCH 1/2] fix(cua-driver/windows): persist config values --- .../platform-windows/src/tools/impl_.rs | 188 +++++++++++++----- .../rust/tests/integration/test_api_parity.py | 69 +++++++ 2 files changed, 210 insertions(+), 47 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs index 9fb49e2059..e7e79f5df3 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs @@ -166,6 +166,54 @@ impl Default for DriverConfig { fn default() -> Self { Self { capture_mode: "som".into(), max_image_dimension: 1568 } } } +fn config_file_path() -> Option { + std::env::var("HOME") + .ok() + .map(|home| std::path::PathBuf::from(home).join(".cua-driver").join("config.json")) +} + +fn load_driver_config() -> DriverConfig { + let mut cfg = DriverConfig::default(); + let path = match config_file_path() { + Some(path) => path, + None => return cfg, + }; + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(_) => return cfg, + }; + let json: Value = match serde_json::from_str(&text) { + Ok(json) => json, + Err(_) => return cfg, + }; + if let Some(mode) = json.get("capture_mode").and_then(|value| value.as_str()) { + cfg.capture_mode = mode.to_owned(); + } + if let Some(dim) = json.get("max_image_dimension").and_then(|value| value.as_u64()) { + if let Ok(dim32) = u32::try_from(dim) { + cfg.max_image_dimension = dim32; + } + } + cfg +} + +fn write_driver_config_key(key: &str, value: &Value) -> Result<(), String> { + let path = config_file_path().ok_or_else(|| "$HOME is not set".to_string())?; + let mut json: Value = path + .exists() + .then(|| std::fs::read_to_string(&path).ok()) + .flatten() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_else(|| json!({})); + json[key] = value.clone(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|err| err.to_string())?; + } + let body = serde_json::to_string_pretty(&json).map_err(|err| err.to_string())?; + std::fs::write(&path, body).map_err(|err| err.to_string())?; + Ok(()) +} + pub struct ResizeRegistry { ratios: std::sync::Mutex>, } @@ -219,7 +267,7 @@ impl ToolState { cursor_registry: Arc::new(CursorRegistry::new()), resize_registry: Arc::new(ResizeRegistry::new()), zoom_registry: Arc::new(ZoomRegistry::new()), - config: Arc::new(RwLock::new(DriverConfig::default())), + config: Arc::new(RwLock::new(load_driver_config())), }) } } @@ -5005,57 +5053,90 @@ impl Tool for SetConfigTool { }) } async fn invoke(&self, args: Value) -> ToolResult { - let mut cfg = self.state.config.write().unwrap(); let mut applied = false; - // Swift-compatible {key, value} shape. - if let (Some(key), Some(val)) = ( - args.get("key").and_then(|v| v.as_str()), - args.get("value"), - ) { - match key { - "capture_mode" => match val.as_str() { - Some(s) => { cfg.capture_mode = s.to_owned(); applied = true; } - None => return ToolResult::error(format!("`capture_mode` must be a string, got {val}.")), - }, - "max_image_dimension" => match val.as_u64() { - Some(n) => { cfg.max_image_dimension = n as u32; applied = true; } - None => return ToolResult::error(format!("`max_image_dimension` must be an integer, got {val}.")), - }, - "experimental_pip" => match val.as_bool() { - Some(b) => { - if let Err(e) = pip_preview::write_config_key("experimental_pip", Value::Bool(b)) { - return ToolResult::error(format!("failed to persist experimental_pip: {e}")); + let mut persisted_capture_mode: Option = None; + let mut persisted_max_image_dimension: Option = None; + let (capture_mode, max_image_dimension) = { + let mut cfg = self.state.config.write().unwrap(); + // Swift-compatible {key, value} shape. + if let (Some(key), Some(val)) = ( + args.get("key").and_then(|v| v.as_str()), + args.get("value"), + ) { + match key { + "capture_mode" => match val.as_str() { + Some(s) => { + cfg.capture_mode = s.to_owned(); + persisted_capture_mode = Some(s.to_owned()); + applied = true; } - applied = true; - } - None => return ToolResult::error(format!("`experimental_pip` must be a boolean, got {val}.")), - }, - "experimental_pip_geometry" => match val.as_str() { - Some(s) => { - if pip_preview::PipGeometry::parse(s).is_none() { - return ToolResult::error(format!( - "experimental_pip_geometry `{s}` is not a valid WxH or WxH+X+Y string" - )); + None => return ToolResult::error(format!("`capture_mode` must be a string, got {val}.")), + }, + "max_image_dimension" => match val.as_u64() { + Some(n) => match u32::try_from(n) { + Ok(dim32) => { + cfg.max_image_dimension = dim32; + persisted_max_image_dimension = Some(dim32); + applied = true; + } + Err(_) => { + return ToolResult::error(format!( + "`max_image_dimension` must fit in u32, got {n}." + )); + } + }, + None => return ToolResult::error(format!("`max_image_dimension` must be an integer, got {val}.")), + }, + "experimental_pip" => match val.as_bool() { + Some(b) => { + if let Err(e) = pip_preview::write_config_key("experimental_pip", Value::Bool(b)) { + return ToolResult::error(format!("failed to persist experimental_pip: {e}")); + } + applied = true; } - if let Err(e) = pip_preview::write_config_key("experimental_pip_geometry", Value::String(s.to_owned())) { - return ToolResult::error(format!("failed to persist experimental_pip_geometry: {e}")); + None => return ToolResult::error(format!("`experimental_pip` must be a boolean, got {val}.")), + }, + "experimental_pip_geometry" => match val.as_str() { + Some(s) => { + if pip_preview::PipGeometry::parse(s).is_none() { + return ToolResult::error(format!( + "experimental_pip_geometry `{s}` is not a valid WxH or WxH+X+Y string" + )); + } + if let Err(e) = pip_preview::write_config_key("experimental_pip_geometry", Value::String(s.to_owned())) { + return ToolResult::error(format!("failed to persist experimental_pip_geometry: {e}")); + } + applied = true; } + None => return ToolResult::error(format!("`experimental_pip_geometry` must be a string, got {val}.")), + }, + other => return ToolResult::error(format!( + "Unknown config key `{other}`. Known: capture_mode, max_image_dimension, experimental_pip, experimental_pip_geometry." + )), + } + } + // Legacy per-field shape. + if let Some(mode) = args.get("capture_mode").and_then(|v| v.as_str()) { + cfg.capture_mode = mode.to_owned(); + persisted_capture_mode = Some(mode.to_owned()); + applied = true; + } + if let Some(dim) = args.get("max_image_dimension").and_then(|v| v.as_u64()) { + match u32::try_from(dim) { + Ok(dim32) => { + cfg.max_image_dimension = dim32; + persisted_max_image_dimension = Some(dim32); applied = true; } - None => return ToolResult::error(format!("`experimental_pip_geometry` must be a string, got {val}.")), - }, - other => return ToolResult::error(format!( - "Unknown config key `{other}`. Known: capture_mode, max_image_dimension, experimental_pip, experimental_pip_geometry." - )), + Err(_) => { + return ToolResult::error(format!( + "`max_image_dimension` must fit in u32, got {dim}." + )); + } + } } - } - // Legacy per-field shape. - if let Some(mode) = args.get("capture_mode").and_then(|v| v.as_str()) { - cfg.capture_mode = mode.to_owned(); applied = true; - } - if let Some(dim) = args.get("max_image_dimension").and_then(|v| v.as_u64()) { - cfg.max_image_dimension = dim as u32; applied = true; - } + (cfg.capture_mode.clone(), cfg.max_image_dimension) + }; if let Some(enabled) = args.get("experimental_pip").and_then(|v| v.as_bool()) { if let Err(e) = pip_preview::write_config_key("experimental_pip", Value::Bool(enabled)) { return ToolResult::error(format!("failed to persist experimental_pip: {e}")); @@ -5076,6 +5157,19 @@ impl Tool for SetConfigTool { if !applied { return ToolResult::error("Missing required string field `key` (or a known legacy per-field)."); } + if let Some(mode) = persisted_capture_mode { + if let Err(err) = write_driver_config_key("capture_mode", &Value::String(mode)) { + tracing::warn!("set_config: failed to persist capture_mode: {err}"); + } + } + if let Some(dim32) = persisted_max_image_dimension { + if let Err(err) = write_driver_config_key( + "max_image_dimension", + &Value::Number(u64::from(dim32).into()), + ) { + tracing::warn!("set_config: failed to persist max_image_dimension: {err}"); + } + } // Emit the same pretty-JSON payload as `get_config` (matches Swift's // `set_config` return shape — both tools echo the full config after). let cursor_enabled = self.state.cursor_registry.all_states() @@ -5087,8 +5181,8 @@ impl Tool for SetConfigTool { "schema_version": 1, "version": env!("CARGO_PKG_VERSION"), "platform": "windows", - "capture_mode": cfg.capture_mode, - "max_image_dimension": cfg.max_image_dimension, + "capture_mode": capture_mode, + "max_image_dimension": max_image_dimension, "agent_cursor": { "enabled": cursor_enabled }, "experimental_pip": pip_enabled, "experimental_pip_geometry": pip_geometry, diff --git a/libs/cua-driver/rust/tests/integration/test_api_parity.py b/libs/cua-driver/rust/tests/integration/test_api_parity.py index 5413319378..fef6a29a3b 100644 --- a/libs/cua-driver/rust/tests/integration/test_api_parity.py +++ b/libs/cua-driver/rust/tests/integration/test_api_parity.py @@ -51,6 +51,7 @@ def _run( args: list[str], timeout: int = 20, stdin: str | None = None, + env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess: return subprocess.run( args, @@ -58,6 +59,7 @@ def _run( text=True, timeout=timeout, input=stdin, + env=env, ) @@ -1167,6 +1169,73 @@ def setUpClass(cls) -> None: class RustParityTests(_ParityMixin, unittest.TestCase): """Run the full parity suite against the Rust cua-driver-rs binary.""" + @unittest.skipUnless(os.name == "nt", "Windows-only config persistence regression") + def test_config_cli_and_daemon_share_persisted_max_image_dimension(self) -> None: + sock = self._tmp_socket() + with tempfile.TemporaryDirectory() as home: + cfg_dir = os.path.join(home, ".cua-driver") + os.makedirs(cfg_dir, exist_ok=True) + cfg_path = os.path.join(cfg_dir, "config.json") + with open(cfg_path, "w", encoding="utf-8") as fh: + json.dump({"max_image_dimension": 777}, fh, indent=2) + + env = os.environ.copy() + env["HOME"] = home + + proc = subprocess.Popen( + [self.binary, "serve", "--socket", sock], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + r = _run([self.binary, "status", "--socket", sock], env=env) + if r.returncode == 0: + break + time.sleep(0.1) + else: + self.fail("daemon did not become ready in 5 s") + + r = _run([self.binary, "config", "--socket", sock], env=env) + self.assertEqual(r.returncode, 0, f"config stderr: {r.stderr}") + shown = json.loads(r.stdout) + self.assertEqual(shown["max_image_dimension"], 777) + + r = _run( + [self.binary, "config", "get", "max_image_dimension", "--socket", sock], + env=env, + ) + self.assertEqual(r.returncode, 0, f"config get stderr: {r.stderr}") + self.assertEqual(r.stdout.strip(), "777") + + r = _run( + [self.binary, "config", "set", "max_image_dimension", "888", "--socket", sock], + env=env, + ) + self.assertEqual(r.returncode, 0, f"config set stderr: {r.stderr}") + + r = _run([self.binary, "config", "--socket", sock], env=env) + self.assertEqual(r.returncode, 0, f"config stderr: {r.stderr}") + shown = json.loads(r.stdout) + self.assertEqual(shown["max_image_dimension"], 888) + + r = _run( + [self.binary, "config", "get", "max_image_dimension", "--socket", sock], + env=env, + ) + self.assertEqual(r.returncode, 0, f"config get stderr: {r.stderr}") + self.assertEqual(r.stdout.strip(), "888") + + with open(cfg_path, encoding="utf-8") as fh: + persisted = json.load(fh) + self.assertEqual(persisted["max_image_dimension"], 888) + finally: + _run([self.binary, "stop", "--socket", sock], env=env) + proc.wait(timeout=3) + @classmethod def setUpClass(cls) -> None: cls.binary = default_binary_path() From e89c3cbd10dcdaf0f370f7344d8a29423746dd33 Mon Sep 17 00:00:00 2001 From: outdog-hwh <2433653167@qq.com> Date: Fri, 26 Jun 2026 10:49:31 +0800 Subject: [PATCH 2/2] test(cua-driver/windows): use named pipe paths in parity tests --- libs/cua-driver/rust/tests/integration/test_api_parity.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/cua-driver/rust/tests/integration/test_api_parity.py b/libs/cua-driver/rust/tests/integration/test_api_parity.py index fef6a29a3b..83eab5ccdc 100644 --- a/libs/cua-driver/rust/tests/integration/test_api_parity.py +++ b/libs/cua-driver/rust/tests/integration/test_api_parity.py @@ -39,6 +39,7 @@ import tempfile import time import unittest +import uuid _HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, _HERE) @@ -613,6 +614,8 @@ def test_update_dry_run_exits_zero(self) -> None: # ── CLI: serve / stop / status daemon lifecycle ─────────────────────────── def _tmp_socket(self) -> str: + if os.name == "nt": + return rf"\\.\pipe\cua-parity-test-{uuid.uuid4().hex}" return tempfile.mktemp(suffix=".sock", prefix="cua-parity-test-") def test_status_exits_1_when_no_daemon(self) -> None: