agentmux_cef\commands/
window.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Window management commands for the CEF host.
5// Ported from src-tauri/src/commands/window.rs.
6//
7// Phase 2: Single-window only. Multi-window commands are stubbed.
8
9use std::sync::Arc;
10
11use cef::{ImplBrowser, ImplBrowserHost};
12
13use crate::state::AppState;
14
15/// Get the current zoom factor.
16pub fn get_zoom_factor(state: &Arc<AppState>) -> serde_json::Value {
17    let factor = *state.zoom_factor.lock();
18    serde_json::json!(factor)
19}
20
21/// Set the zoom factor.
22/// CEF zoom uses a logarithmic scale: zoom_level = log2(zoom_factor)
23/// So factor 1.0 = level 0, factor 2.0 = level 1, factor 0.5 = level -1
24pub fn set_zoom_factor(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
25    let factor = args
26        .get("factor")
27        .and_then(|v| v.as_f64())
28        .ok_or_else(|| "Missing factor".to_string())?;
29
30    let factor = factor.clamp(0.5, 3.0);
31    *state.zoom_factor.lock() = factor;
32
33    // Convert to CEF zoom level (log base 1.2)
34    // CEF uses: zoom_factor = 1.2 ^ zoom_level
35    // So: zoom_level = log(zoom_factor) / log(1.2)
36    let zoom_level = factor.ln() / 1.2_f64.ln();
37
38    // NOTE: host.set_zoom_level() deadlocks from IPC thread, and post_task
39    // crashes with current CEF bindings. Zoom is applied via CSS on the frontend.
40    // The zoom_factor state is stored for get_zoom_factor queries.
41
42    // Emit zoom-factor-change event
43    crate::events::emit_event_from_state(state, "zoom-factor-change", &serde_json::json!(factor));
44
45    Ok(serde_json::Value::Null)
46}
47
48/// Close the window. Args: optional `{ "label": string }`; defaults to "main".
49/// (Linux/macOS need the label to act on the right window when called from
50/// a non-main window. Windows resolves per-process via find_own_top_level_window.)
51pub fn close_window(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
52    #[cfg(target_os = "windows")]
53    unsafe {
54        use windows_sys::Win32::UI::WindowsAndMessaging::*;
55        let hwnd = find_own_top_level_window();
56        if !hwnd.is_null() {
57            PostMessageW(hwnd, WM_CLOSE, 0, 0);
58            return Ok(serde_json::Value::Null);
59        }
60    }
61    #[cfg(not(target_os = "windows"))]
62    {
63        let label = args.get("label").and_then(|v| v.as_str()).unwrap_or("main");
64        crate::ui_tasks::post_close_window(state, label);
65    }
66    let _ = (state, args);
67    Ok(serde_json::Value::Null)
68}
69
70/// Close a specific window by label. Used by the tear-off Phase 4
71/// merge path: after the candidate window pulls the dragged tab into
72/// its own workspace via MoveTabToWorkspace, the dragged window is
73/// empty and should be destroyed. Posts WM_CLOSE on Win32; uses the
74/// existing UI-thread close task on other platforms.
75pub fn close_window_by_label(
76    state: &Arc<AppState>,
77    args: &serde_json::Value,
78) -> Result<serde_json::Value, String> {
79    let label = args
80        .get("label")
81        .and_then(|v| v.as_str())
82        .ok_or_else(|| "missing label".to_string())?
83        .to_string();
84
85    #[cfg(target_os = "windows")]
86    unsafe {
87        use cef::{ImplBrowser, ImplBrowserHost};
88        use windows_sys::Win32::UI::WindowsAndMessaging::{PostMessageW, WM_CLOSE};
89        // Phase H.2.b — reducer-aware lookup with fallback.
90        if let Some(browser) = state.get_browser(&label) {
91            if let Some(host) = browser.host() {
92                let hwnd = host.window_handle();
93                if !hwnd.0.is_null() {
94                    PostMessageW(hwnd.0 as *mut std::ffi::c_void, WM_CLOSE, 0, 0);
95                    return Ok(serde_json::Value::Null);
96                }
97            }
98        }
99        return Err(format!("no top-level HWND for label {}", label));
100    }
101
102    #[cfg(not(target_os = "windows"))]
103    {
104        crate::ui_tasks::post_close_window(state, &label);
105        Ok(serde_json::Value::Null)
106    }
107}
108
109/// Minimize the window. Args: optional `{ "label": string }`; defaults to "main".
110pub fn minimize_window(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
111    #[cfg(target_os = "windows")]
112    unsafe {
113        use windows_sys::Win32::UI::WindowsAndMessaging::*;
114        let hwnd = find_own_top_level_window();
115        if !hwnd.is_null() {
116            ShowWindow(hwnd, SW_MINIMIZE);
117            return Ok(serde_json::Value::Null);
118        }
119    }
120    #[cfg(not(target_os = "windows"))]
121    {
122        let label = args.get("label").and_then(|v| v.as_str()).unwrap_or("main");
123        crate::ui_tasks::post_minimize_window(state, label);
124    }
125    let _ = (state, args);
126    Ok(serde_json::Value::Null)
127}
128
129/// Maximize/unmaximize the window (toggle).
130///
131/// Args: `{ "label": string | null }` — optional window label. When omitted,
132/// defaults to "main" (preserves single-window-build behavior). The frontend
133/// reads its own label from the `?windowLabel=…` URL query and passes it
134/// here so non-main windows act on the right CEF window.
135pub fn maximize_window(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
136    #[cfg(target_os = "windows")]
137    unsafe {
138        use windows_sys::Win32::UI::WindowsAndMessaging::*;
139        let hwnd = find_own_top_level_window();
140        if !hwnd.is_null() {
141            let mut placement: WINDOWPLACEMENT = std::mem::zeroed();
142            placement.length = std::mem::size_of::<WINDOWPLACEMENT>() as u32;
143            GetWindowPlacement(hwnd, &mut placement);
144            if placement.showCmd == SW_MAXIMIZE as u32 {
145                ShowWindow(hwnd, SW_RESTORE);
146            } else {
147                ShowWindow(hwnd, SW_MAXIMIZE);
148            }
149            return Ok(serde_json::Value::Null);
150        }
151    }
152    #[cfg(not(target_os = "windows"))]
153    {
154        let label = args.get("label").and_then(|v| v.as_str()).unwrap_or("main");
155        crate::ui_tasks::post_maximize_window(state, label);
156    }
157    let _ = (state, args);
158    Ok(serde_json::Value::Null)
159}
160
161/// Get the current window position on screen.
162pub fn get_window_position(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
163    #[cfg(target_os = "windows")]
164    unsafe {
165        use windows_sys::Win32::UI::WindowsAndMessaging::*;
166        use windows_sys::Win32::Foundation::RECT;
167        let hwnd = find_own_top_level_window();
168        if !hwnd.is_null() {
169            let mut rect: RECT = std::mem::zeroed();
170            GetWindowRect(hwnd, &mut rect);
171            return Ok(serde_json::json!({ "x": rect.left, "y": rect.top }));
172        }
173    }
174    let _ = state;
175    Ok(serde_json::json!({ "x": 0, "y": 0 }))
176}
177
178/// Move the window by a delta (dx, dy) from its current position.
179///
180/// **Prefer `set_window_position` for drag flows.** This function reads
181/// the current rect via `GetWindowRect` then SetWindowPos with the new
182/// origin — under rapid concurrent calls (mousemove drag), in-flight
183/// IPCs all read the same stale rect and only one delta gets applied.
184/// `set_window_position` is self-contained (no read-modify-write) and
185/// idempotent under concurrency.
186pub fn move_window_by(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
187    let dx = args.get("dx").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
188    let dy = args.get("dy").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
189    let label = args.get("label").and_then(|v| v.as_str()).unwrap_or("main");
190
191    #[cfg(target_os = "windows")]
192    unsafe {
193        use windows_sys::Win32::UI::WindowsAndMessaging::*;
194        use windows_sys::Win32::Foundation::RECT;
195        let hwnd = find_own_top_level_window();
196        if !hwnd.is_null() {
197            let mut rect: RECT = std::mem::zeroed();
198            GetWindowRect(hwnd, &mut rect);
199            let width = rect.right - rect.left;
200            let height = rect.bottom - rect.top;
201            SetWindowPos(
202                hwnd,
203                std::ptr::null_mut(),
204                rect.left + dx,
205                rect.top + dy,
206                width,
207                height,
208                // 0x0014 = SWP_NOZORDER (0x0004) | SWP_NOACTIVATE (0x0010).
209                0x0014,
210            );
211            return Ok(serde_json::Value::Null);
212        }
213    }
214    #[cfg(not(target_os = "windows"))]
215    crate::ui_tasks::post_move_window(state, label, dx, dy);
216    let _ = (state, label);
217    Ok(serde_json::Value::Null)
218}
219
220/// Move the window to an absolute screen position (x, y).
221/// Each call is self-contained — no read-modify-write — so concurrent in-flight
222/// calls are idempotent: the last write wins, which is exactly correct for drag.
223pub fn set_window_position(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
224    let x = args.get("x").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
225    let y = args.get("y").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
226    let label = args.get("label").and_then(|v| v.as_str()).unwrap_or("main");
227
228    #[cfg(target_os = "windows")]
229    unsafe {
230        use windows_sys::Win32::UI::WindowsAndMessaging::*;
231        use windows_sys::Win32::Foundation::RECT;
232        let hwnd = find_own_top_level_window();
233        if !hwnd.is_null() {
234            let mut rect: RECT = std::mem::zeroed();
235            GetWindowRect(hwnd, &mut rect);
236            let width = rect.right - rect.left;
237            let height = rect.bottom - rect.top;
238            SetWindowPos(
239                hwnd,
240                std::ptr::null_mut(),
241                x,
242                y,
243                width,
244                height,
245                // 0x0014 = SWP_NOZORDER (0x0004) | SWP_NOACTIVATE (0x0010).
246                // (Width/height are still passed explicitly above so size
247                // is preserved without needing SWP_NOSIZE.)
248                0x0014,
249            );
250            return Ok(serde_json::Value::Null);
251        }
252    }
253    #[cfg(not(target_os = "windows"))]
254    crate::ui_tasks::post_set_window_position(state, label, x, y);
255    let _ = (state, label);
256    Ok(serde_json::Value::Null)
257}
258
259/// Initiate window drag (for frameless windows).
260/// Windows: sends WM_NCLBUTTONDOWN/HTCAPTION via Win32 — find_own_top_level_window
261/// resolves the per-process HWND so multi-window works without a label.
262/// Linux/macOS: dispatches CefWindow::BeginWindowDrag on the UI thread; needs
263/// the source window's label so non-main windows drag themselves rather than
264/// the main window. Frontend reads `?windowLabel=…` from its URL and passes
265/// it here; missing → "main" for backward compatibility.
266pub fn start_window_drag(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
267    #[cfg(target_os = "windows")]
268    unsafe {
269        use windows_sys::Win32::UI::WindowsAndMessaging::*;
270        use windows_sys::Win32::UI::Input::KeyboardAndMouse::ReleaseCapture;
271        let hwnd = find_own_top_level_window();
272        if !hwnd.is_null() {
273            ReleaseCapture();
274            SendMessageW(hwnd, WM_NCLBUTTONDOWN, 2 /* HTCAPTION */, 0);
275            return Ok(serde_json::Value::Null);
276        }
277    }
278    #[cfg(not(target_os = "windows"))]
279    {
280        let label = args.get("label").and_then(|v| v.as_str()).unwrap_or("main");
281        crate::ui_tasks::post_start_drag(state, label);
282    }
283    let _ = (state, args);
284    Ok(serde_json::Value::Null)
285}
286
287/// Set window transparency/blur effects for a single window.
288///
289/// Targets exactly the window identified by `label` (from the frontend's URL
290/// `windowLabel` param). Uses the `window_hwnds` map populated by
291/// `capture_hwnd_for_label`. Falls back to `find_all_own_windows()` only
292/// if the label's HWND hasn't been captured yet (e.g. very early startup).
293pub fn set_window_transparency(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
294    let transparent = args.get("transparent").and_then(|v| v.as_bool()).unwrap_or(false);
295    let opacity = args.get("opacity").and_then(|v| v.as_f64()).unwrap_or(0.8);
296    let label = args.get("label").and_then(|v| v.as_str()).unwrap_or("main").to_string();
297    tracing::info!("set_window_transparency: label={} transparent={} opacity={}", label, transparent, opacity);
298
299    #[cfg(target_os = "windows")]
300    {
301        let hwnd_raw = state.window_hwnds.lock().get(label.as_str()).copied();
302        let hwnds: Vec<*mut std::ffi::c_void> = if let Some(raw) = hwnd_raw {
303            vec![raw as *mut std::ffi::c_void]
304        } else {
305            // HWND not yet captured (early startup). Fall back to process
306            // enumeration as a best-effort. This path is temporary — once
307            // set_window_init_status fires, future calls use the map.
308            tracing::warn!("set_window_transparency: no hwnd for label={}, falling back to find_all_own_windows", label);
309            find_all_own_windows()
310        };
311        for hwnd in hwnds {
312            unsafe {
313                if transparent {
314                    apply_window_opacity(hwnd, opacity);
315                } else {
316                    remove_window_opacity(hwnd);
317                }
318            }
319            tracing::info!("set_window_transparency: applied to {:?}", hwnd);
320        }
321    }
322
323    let _ = state;
324    #[cfg(not(target_os = "windows"))]
325    let _ = (transparent, opacity);
326
327    Ok(serde_json::Value::Null)
328}
329
330/// Find ALL visible top-level windows belonging to this process.
331#[cfg(target_os = "windows")]
332fn find_all_own_windows() -> Vec<*mut std::ffi::c_void> {
333    use windows_sys::Win32::UI::WindowsAndMessaging::*;
334    use windows_sys::Win32::System::Threading::GetCurrentProcessId;
335
336    let mut results: Vec<*mut std::ffi::c_void> = Vec::new();
337
338    unsafe extern "system" fn enum_callback(
339        hwnd: *mut std::ffi::c_void,
340        lparam: isize,
341    ) -> i32 {
342        use windows_sys::Win32::System::Threading::GetCurrentProcessId;
343        let mut window_pid: u32 = 0;
344        GetWindowThreadProcessId(hwnd, &mut window_pid);
345        if window_pid == GetCurrentProcessId() && IsWindowVisible(hwnd) != 0 {
346            let results = &mut *(lparam as *mut Vec<*mut std::ffi::c_void>);
347            results.push(hwnd);
348        }
349        1 // Continue
350    }
351
352    unsafe {
353        EnumWindows(Some(enum_callback), &mut results as *mut _ as isize);
354    }
355    results
356}
357
358/// Find the top-level window belonging to this process.
359/// In CEF Views mode, browser.host().window_handle() returns NULL,
360/// so we enumerate windows and find ours by process ID.
361#[cfg(target_os = "windows")]
362pub(crate) unsafe fn find_own_top_level_window() -> *mut std::ffi::c_void {
363    use windows_sys::Win32::UI::WindowsAndMessaging::*;
364    use windows_sys::Win32::System::Threading::GetCurrentProcessId;
365
366    let pid = GetCurrentProcessId();
367    let mut result: *mut std::ffi::c_void = std::ptr::null_mut();
368
369    unsafe extern "system" fn enum_callback(
370        hwnd: *mut std::ffi::c_void,
371        lparam: isize,
372    ) -> i32 {
373        use windows_sys::Win32::System::Threading::GetCurrentProcessId;
374        let mut window_pid: u32 = 0;
375        GetWindowThreadProcessId(hwnd, &mut window_pid);
376        if window_pid == GetCurrentProcessId() && IsWindowVisible(hwnd) != 0 {
377            // Store the HWND in the pointer passed via lparam
378            let result_ptr = lparam as *mut *mut std::ffi::c_void;
379            *result_ptr = hwnd;
380            return 0; // Stop enumeration
381        }
382        1 // Continue
383    }
384
385    let _ = pid; // Used inside callback via GetCurrentProcessId()
386    EnumWindows(
387        Some(enum_callback),
388        &mut result as *mut _ as isize,
389    );
390    result
391}
392
393/// Apply window-level opacity via WS_EX_LAYERED + SetLayeredWindowAttributes.
394/// This makes the entire window semi-transparent (content + chrome).
395#[cfg(target_os = "windows")]
396unsafe fn apply_window_opacity(hwnd: *mut std::ffi::c_void, opacity: f64) {
397    use windows_sys::Win32::UI::WindowsAndMessaging::*;
398
399    let alpha = (opacity.clamp(0.0, 1.0) * 255.0) as u8;
400
401    // Add WS_EX_LAYERED extended style
402    let ex_style = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
403    SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex_style | WS_EX_LAYERED as isize);
404
405    // LWA_ALPHA = 0x02
406    let result = SetLayeredWindowAttributes(hwnd, 0, alpha, 0x02);
407    if result != 0 {
408        tracing::info!("Applied window opacity: {} (alpha={})", opacity, alpha);
409    } else {
410        tracing::warn!("SetLayeredWindowAttributes failed");
411    }
412}
413
414/// Remove window opacity — restore to fully opaque by removing WS_EX_LAYERED.
415#[cfg(target_os = "windows")]
416unsafe fn remove_window_opacity(hwnd: *mut std::ffi::c_void) {
417    use windows_sys::Win32::UI::WindowsAndMessaging::*;
418    let ex_style = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
419    if (ex_style & WS_EX_LAYERED as isize) != 0 {
420        SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex_style & !(WS_EX_LAYERED as isize));
421        tracing::info!("Removed window opacity (WS_EX_LAYERED cleared)");
422    }
423}
424
425/// Get the current window label.
426/// The frontend passes its own label (extracted from URL params) as an arg.
427pub fn get_window_label(args: &serde_json::Value) -> serde_json::Value {
428    let label = args.get("label").and_then(|v| v.as_str()).unwrap_or("main");
429    serde_json::json!(label)
430}
431
432/// Check if this is the main window.
433/// The frontend passes its own label (extracted from URL params) as an arg.
434pub fn is_main_window(args: &serde_json::Value) -> serde_json::Value {
435    let label = args.get("label").and_then(|v| v.as_str()).unwrap_or("main");
436    serde_json::json!(label == "main")
437}
438
439/// Return the OS double-click interval in milliseconds.
440/// On Windows: `GetDoubleClickTime()` — typically 500ms, user-configurable
441/// via Mouse settings. On non-Windows: hardcoded 500ms (the Win32 default,
442/// also a common cross-platform default; Phase 7 can refine per platform).
443///
444/// Used by the InstancePanel to defer single-click focus past the user's
445/// dblclick threshold so dblclick-to-rename works for everyone, not just
446/// users with the default-or-faster setting. Without this query, a fixed
447/// constant would make rename unreliable for slow double-clickers
448/// (codex PR #569 round-2 P2).
449pub fn get_double_click_time() -> serde_json::Value {
450    #[cfg(target_os = "windows")]
451    {
452        let ms = unsafe { windows_sys::Win32::UI::Input::KeyboardAndMouse::GetDoubleClickTime() };
453        serde_json::json!(ms)
454    }
455    #[cfg(not(target_os = "windows"))]
456    {
457        serde_json::json!(500u32)
458    }
459}
460
461/// List all open window instances with their backend window IDs.
462/// Same filtering as `list_windows` (excludes unpromoted pool windows
463/// and browser-pane child HWNDs), but returns `[{label, windowId}]`
464/// pairs so the frontend can resolve per-window backend objects
465/// (Window record → meta["window:displayname"], etc.) without an
466/// extra round-trip per row.
467///
468/// `windowId` is `None` for windows that haven't yet completed the
469/// `register_backend_window` round-trip — typically a freshly-spawned
470/// window before its frontend has finished init. Callers should
471/// fall back to label/index-based naming in that case.
472pub fn list_window_instances(state: &Arc<AppState>) -> serde_json::Value {
473    // Atomic snapshot — pool inventory + browsers under ONE lock.
474    // Two-lock variants race against `promote_pool_window` between
475    // the reads and would let a just-promoted user window be
476    // excluded (or admit a still-hidden pool window).
477    let (pool_labels, browsers) = state.user_visibility_snapshot();
478    let labels: Vec<String> = browsers
479        .into_iter()
480        .map(|(l, _)| l)
481        .filter(|l| !pool_labels.contains(l.as_str()) && !l.starts_with("browser-pane-"))
482        .collect();
483    // Read backend window IDs via `state.backend_window_id()`,
484    // which queries the launcher-fed `shadow_backend_window_ids`
485    // (sole source of truth post-B.5e). Resolve labels OUTSIDE
486    // the browsers lock to avoid nesting (browsers + shadow).
487    let entries: Vec<serde_json::Value> = labels
488        .iter()
489        .map(|l| {
490            serde_json::json!({
491                "label": l,
492                "windowId": state.backend_window_id(l),
493            })
494        })
495        .collect();
496    serde_json::json!(entries)
497}
498
499/// List all open window labels, excluding unpromoted pool windows.
500/// Pool windows are pre-warmed tear-off scratch windows kept hidden
501/// from the user (WS_EX_TOOLWINDOW, no taskbar entry). Including them
502/// in `list_windows` inflates the frontend's InstancePanel row count
503/// with phantom entries the user can't see or focus.
504///
505/// Uses `state.user_visibility_snapshot()` — atomic read of pool
506/// inventory (`unpromoted` ∪ `pool.queue`) and the browser registry
507/// under one host_state lock. Both `unpromoted` (populated at spawn
508/// time) and `pool.queue` (populated after renderer-ready, before
509/// promote) are host-internal: the window is hidden off-screen and
510/// has no UI a user could see or focus. The atomic read is required
511/// because a two-lock variant races against `promote_pool_window`.
512pub fn list_windows(state: &Arc<AppState>) -> serde_json::Value {
513    let (pool_labels, browsers) = state.user_visibility_snapshot();
514    let labels: Vec<String> = browsers
515        .into_iter()
516        .map(|(l, _)| l)
517        .filter(|l| !pool_labels.contains(l.as_str()))
518        .collect();
519    serde_json::json!(labels)
520}
521
522/// Focus a specific window by label.
523///
524/// Uses the CEF Views `Window::activate()` API on all platforms (via
525/// `post_focus_window` → `FocusWindowTask`). On Windows in Views mode,
526/// `browser.host().window_handle()` returns NULL — the previous direct
527/// SetForegroundWindow path silently failed there. Views' `activate()`
528/// resolves the actual top-level HWND through `browser_view_get_for_browser
529/// → window()` which is the only correct way to reach it in Views mode.
530pub fn focus_window(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
531    let label = args.get("label").and_then(|v| v.as_str()).unwrap_or("main");
532    crate::ui_tasks::post_focus_window(state, label);
533    Ok(serde_json::Value::Null)
534}
535
536/// Get the instance number for the current window.
537///
538/// Reads from `state.instance_num()` which queries the launcher-fed
539/// `shadow_instance_registry` (B.5e — sole source of truth post-migration).
540/// Brief race window for early lookups: see `app-init.ts` retry logic.
541pub fn get_instance_number(state: &Arc<AppState>, args: &serde_json::Value) -> serde_json::Value {
542    let label = args
543        .get("label")
544        .and_then(|v| v.as_str())
545        .unwrap_or("main");
546    serde_json::json!(state.instance_num(label).unwrap_or(1))
547}
548
549/// Register the backend window ID for a window label.
550/// Called by the frontend after it has initialized its backend Window object.
551/// Used by `on_before_close` to notify the backend when a secondary window closes.
552pub fn register_backend_window(_state: &Arc<AppState>, args: &serde_json::Value) -> serde_json::Value {
553    let label = args.get("label").and_then(|v| v.as_str()).unwrap_or("main");
554    let window_id = args.get("window_id").and_then(|v| v.as_str()).unwrap_or("");
555    tracing::info!(label = %label, window_id = %window_id, "[window] register_backend_window received");
556    crate::client::dlog(&format!("register_backend_window: label={} window_id={}", label, window_id));
557    if !window_id.is_empty() {
558        // Phase B.5 (window_id_map step d) — host no longer mutates
559        // `window_id_map` locally. The launcher's
560        // `state.backend_window_ids` (B.5 step a) is sole authority;
561        // we just send the report and the shadow update populates
562        // the host-side projection.
563        tracing::info!(label = %label, window_id = %window_id, "[window] registered backend window ID");
564        crate::launcher_ipc::report_backend_window_id_registered(
565            label.to_string(),
566            window_id.to_string(),
567        );
568        // Phase B.7.3.3 — the launcher's
569        // `Event::BackendWindowIdRegistered` (delivered via the CEF
570        // JS bridge) carries the label → windowId mapping change to
571        // every renderer's reducer. No sync emit here.
572    } else {
573        tracing::warn!(label = %label, "[window] register_backend_window called with empty window_id — skipped");
574    }
575    serde_json::Value::Null
576}
577
578/// Toggle DevTools for the main window.
579///
580/// Uses CEF's native show_dev_tools() API, which triggers
581/// BrowserViewDelegate::on_popup_browser_view_created with is_devtools=1.
582/// That callback creates a top-level CefWindow with a native title bar,
583/// producing a standalone DevTools window — identical to Tauri's open_devtools().
584pub fn toggle_devtools(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
585    let label = args.get("label").and_then(|v| v.as_str()).unwrap_or("main");
586    crate::ui_tasks::post_show_dev_tools(state, label);
587    Ok(serde_json::Value::Null)
588}
589
590/// Resolve the base URL for the frontend.
591/// Production: IPC server serves static files from `frontend/` next to the exe.
592/// Dev: Vite dev server at `http://localhost:5173`.
593pub(crate) fn resolve_frontend_base_url(ipc_port: u16) -> String {
594    // Detect dev mode. Two reachable scenarios:
595    //   a) Launcher-managed: AGENTMUX_RUNTIME_MODE is set, from_env()
596    //      returns Some.
597    //   b) Standalone `task dev`: env absent. Fall through to
598    //      RuntimeMode::current() against the host exe path so the
599    //      same `dist/cef-dev/` build dir → Dev classification fires
600    //      that the launcher would have used.
601    let mode = agentmux_common::RuntimeMode::from_env().or_else(|| {
602        std::env::current_exe()
603            .ok()
604            .and_then(|p| p.parent().map(|d| d.to_path_buf()))
605            .map(|d| agentmux_common::RuntimeMode::current(&d))
606    });
607    if matches!(mode, Some(agentmux_common::RuntimeMode::Dev { .. })) {
608        return "http://localhost:5173".to_string();
609    }
610    let exe_dir = std::env::current_exe()
611        .ok()
612        .and_then(|p| p.parent().map(|d| d.to_path_buf()));
613    let has_frontend = exe_dir
614        .as_ref()
615        .map(|d| d.join("frontend/index.html").exists())
616        .unwrap_or(false);
617    if has_frontend {
618        format!("http://127.0.0.1:{}", ipc_port)
619    } else {
620        "http://localhost:5173".to_string()
621    }
622}
623
624/// Open a new full AgentMux instance (status-bar version click, Ctrl+Shift+N,
625/// second `agentmux.exe` launch). Independent top-level window, own taskbar
626/// entry, independent lifecycle. See
627/// `docs/specs/SPEC_MULTIWINDOW_TASKBAR_GROUPING.md`.
628pub fn open_new_window(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
629    open_window_with_kind(state, crate::state::WindowKind::FullInstance, None)
630}
631
632/// Open a sub-window tied to `parent_instance_id`. **Not exposed to users** —
633/// reserved for agent / backend callers that need a transient auxiliary
634/// top-level window (tool-spawned panels, diff views, etc.). Sub-windows are
635/// hidden from the taskbar via `ITaskbarList::DeleteTab` and close when their
636/// parent full instance closes.
637pub fn open_subwindow(
638    state: &Arc<AppState>,
639    parent_instance_id: String,
640) -> Result<serde_json::Value, String> {
641    // Reject if the parent isn't a known live FullInstance — prevents
642    // orphan sub-windows and enforces the lifecycle rule in the spec.
643    //
644    // Phase B.5 (window_meta step d, refined twice):
645    // * Round-1 fix used `state.window_meta()` (shadow-first), which
646    //   covered the task-dev-mode regression but allowed a NEW orphan
647    //   bug: shadow lags on close, so during the gap between host's
648    //   sync `on_before_close` removal and the launcher's async
649    //   `WindowClosed` event arrival, this check could still see a
650    //   already-closing parent. (codex P2 PR #592 round-2.)
651    // * Refined: read host_meta DIRECTLY for this liveness check.
652    //   Host_meta is synchronously written in on_after_created and
653    //   removed in on_before_close (per the round-2 step-d
654    //   refinement keeping host_meta as a sync cache), so it
655    //   correctly reflects "is the parent currently open" without
656    //   shadow's async lag. Works in `task dev` mode too (host_meta
657    //   populated by on_after_created regardless of launcher
658    //   presence).
659    let parent_ok = state
660        .window_meta
661        .lock()
662        .get(&parent_instance_id)
663        .map(|m| m.kind == crate::state::WindowKind::FullInstance)
664        .unwrap_or(false);
665    if !parent_ok {
666        return Err(format!(
667            "open_subwindow: unknown or non-full-instance parent label={parent_instance_id}"
668        ));
669    }
670    open_window_with_kind(
671        state,
672        crate::state::WindowKind::Subwindow,
673        Some(parent_instance_id),
674    )
675}
676
677fn open_window_with_kind(
678    state: &Arc<AppState>,
679    kind: crate::state::WindowKind,
680    parent_instance_id: Option<String>,
681) -> Result<serde_json::Value, String> {
682    // PR #6 H.7 — refuse top-level creation while any pane is mid-close.
683    // See `SPEC_WINDOW_FLEET_REDUCER_2026-05-02.md` and the smoke retro
684    // at `docs/retro/smoke-test-0.33.586-and-pr5-plan-2026-05-02.md`:
685    // creating a top-level CEF window while a pane is in `Closing` hits
686    // a Chromium v146 deadlock (HiddenSinceOpen + IPC backpressure)
687    // that wedges the message loop. Frontend should retry on next tick.
688    if state.any_browser_pane_closing() {
689        tracing::warn!(
690            target: "wfr:gate",
691            "[wfr:gate] open_window refused — pane is mid-close (H.7 invariant)"
692        );
693        return Err("a pane is currently closing; retry shortly".to_string());
694    }
695
696    let window_id = uuid::Uuid::new_v4();
697    let label = format!("window-{}", window_id.simple());
698
699    let ipc_port = *state.ipc_port.lock();
700    let ipc_token = &state.ipc_token;
701    let base_url = resolve_frontend_base_url(ipc_port);
702
703    let separator = if base_url.contains('?') { "&" } else { "?" };
704    let url = format!(
705        "{}{}ipc_port={}&ipc_token={}&windowLabel={}",
706        base_url, separator, ipc_port, ipc_token, label
707    );
708
709    tracing::info!(label = %label, kind = ?kind, parent = ?parent_instance_id, "[window] open window");
710
711    // Phase B.5 (window_meta step d) — push the pre-create handoff
712    // (label + kind + parent). Replaces the previous parallel
713    // `window_meta.insert` + `pending_window_labels.push` pair.
714    let (pos_x, pos_y) = get_offset_position();
715    let (win_w, win_h) = get_secondary_window_size(pos_x, pos_y);
716
717    // Phase F.1 — routed through the host reducer.
718    state.host_dispatch(
719        crate::reducer::HostCommand::EnqueuePendingWindowCreation {
720            entry: crate::state::PendingWindowCreation {
721                label: label.clone(),
722                kind,
723                parent_instance_id,
724            },
725        },
726    );
727
728    // Post to CEF UI thread — window_create_top_level must run there.
729    // true = frameless: secondary app windows use the same custom title bar as main.
730    crate::ui_tasks::post_create_window(
731        state, &url, &label, pos_x, pos_y, win_w, win_h,
732        true,
733    );
734
735    // Phase B.7.3.3 — typed launcher events drive InstancePanel
736    // atoms via the CEF JS bridge; no sync emit here.
737
738    Ok(serde_json::json!(label))
739}
740
741/// Get an offset position for a new window: 30px right and 30px down from the current window.
742fn get_offset_position() -> (i32, i32) {
743    #[cfg(target_os = "windows")]
744    unsafe {
745        use windows_sys::Win32::UI::WindowsAndMessaging::*;
746        use windows_sys::Win32::Foundation::RECT;
747        let hwnd = find_own_top_level_window();
748        if !hwnd.is_null() {
749            let mut rect: RECT = std::mem::zeroed();
750            GetWindowRect(hwnd, &mut rect);
751            return (rect.left + 30, rect.top + 30);
752        }
753    }
754    #[cfg(target_os = "windows")]
755    {
756        use windows_sys::Win32::UI::WindowsAndMessaging::CW_USEDEFAULT;
757        return (CW_USEDEFAULT, CW_USEDEFAULT);
758    }
759    #[cfg(not(target_os = "windows"))]
760    (100, 100)
761}
762
763/// Compute 70% of the monitor's work area for a secondary window at (px, py).
764/// Falls back to 1200x800 if the monitor can't be determined.
765fn get_secondary_window_size(px: i32, py: i32) -> (i32, i32) {
766    #[cfg(target_os = "windows")]
767    {
768        use crate::app::get_monitor_work_area;
769        if let Some((_x, _y, work_w, work_h)) = get_monitor_work_area(px, py) {
770            return ((work_w as f64 * 0.70) as i32, (work_h as f64 * 0.70) as i32);
771        }
772    }
773    (1200, 800)
774}
775
776// ── Per-window opacity (SPEC_PER_WINDOW_OPACITY_2026-05-14.md) ───────────────
777
778/// Capture and store the HWND for `label` in `AppState::window_hwnds`.
779///
780/// Called from `set_window_init_status` once the frontend signals "ready".
781/// Two-pass approach:
782/// 1. Fast path: `browser.host().window_handle()` — may be non-NULL by this
783///    point even in CEF Views mode (window fully shown).
784/// 2. Fallback: enumerate all process-owned visible HWNDs and pick the one
785///    not yet registered in `window_hwnds`. Reliable because windows are
786///    opened sequentially (pool windows are hidden before promotion).
787#[cfg(target_os = "windows")]
788pub(crate) fn capture_hwnd_for_label(state: &Arc<AppState>, label: &str) {
789    use cef::ImplBrowserHost;
790    // Fast path.
791    if let Some(mut browser) = state.get_browser(label) {
792        if let Some(host) = browser.host() {
793            let hwnd = host.window_handle();
794            if !hwnd.0.is_null() {
795                state.window_hwnds.lock().insert(label.to_string(), hwnd.0 as isize);
796                tracing::debug!("[opacity] captured hwnd fast-path label={} hwnd={:#x}", label, hwnd.0 as isize);
797                return;
798            }
799        }
800    }
801    // Fallback: pick the first visible HWND not already mapped.
802    let known: std::collections::HashSet<isize> = state.window_hwnds.lock().values().cloned().collect();
803    for hwnd_raw in find_all_own_windows() {
804        let raw = hwnd_raw as isize;
805        if !known.contains(&raw) {
806            state.window_hwnds.lock().insert(label.to_string(), raw);
807            tracing::debug!("[opacity] captured hwnd fallback label={} hwnd={:#x}", label, raw);
808            return;
809        }
810    }
811    tracing::warn!("[opacity] capture_hwnd_for_label: no available HWND for label={}", label);
812}
813
814/// Set opacity on exactly one window by label.
815///
816/// Routes through the host reducer (`HostCommand::SetWindowOpacity`) so the
817/// change is auditable. The reducer emits `WindowOpacityApplied`; `host_dispatch`
818/// reads the event and calls the Win32 helper directly (Win32 window-style ops
819/// are safe from any thread). Replaces the global `set_window_transparency` path
820/// for per-window calls.
821pub fn set_window_opacity(
822    state: &Arc<AppState>,
823    args: &serde_json::Value,
824) -> Result<serde_json::Value, String> {
825    let label = args
826        .get("label")
827        .and_then(|v| v.as_str())
828        .ok_or("set_window_opacity: missing label")?
829        .to_string();
830    let opacity = args
831        .get("opacity")
832        .and_then(|v| v.as_f64())
833        .unwrap_or(1.0)
834        .clamp(0.0, 1.0);
835
836    let out = state.host_dispatch(crate::reducer::HostCommand::SetWindowOpacity {
837        label: label.clone(),
838        opacity: opacity as f32,
839    });
840
841    // Apply Win32 side-effect synchronously based on emitted event.
842    // Reagent P1 on #868: the reducer emits `WindowOpacityApplied` for
843    // opacity < 1.0 (clamped translucent value) and `WindowOpacityCleared`
844    // for opacity >= 1.0. Matching only `WindowOpacityApplied` left
845    // windows semi-transparent after the user restored full opacity.
846    // Match both arms.
847    #[cfg(target_os = "windows")]
848    for ev in &out.events {
849        match ev {
850            crate::reducer::HostEvent::WindowOpacityApplied { label: ev_label, opacity: ev_opacity, .. } => {
851                let hwnd_raw = state.window_hwnds.lock().get(ev_label.as_str()).copied();
852                if let Some(raw) = hwnd_raw {
853                    let hwnd = raw as *mut std::ffi::c_void;
854                    unsafe { apply_window_opacity(hwnd, *ev_opacity as f64); }
855                } else {
856                    tracing::warn!("[opacity] set_window_opacity: no hwnd for label={}", ev_label);
857                }
858            }
859            crate::reducer::HostEvent::WindowOpacityCleared { label: ev_label, .. } => {
860                let hwnd_raw = state.window_hwnds.lock().get(ev_label.as_str()).copied();
861                if let Some(raw) = hwnd_raw {
862                    let hwnd = raw as *mut std::ffi::c_void;
863                    unsafe { remove_window_opacity(hwnd); }
864                } else {
865                    tracing::warn!("[opacity] set_window_opacity: no hwnd for label={} (clear)", ev_label);
866                }
867            }
868            _ => {}
869        }
870    }
871
872    let _ = out;
873    Ok(serde_json::Value::Null)
874}
875
876/// Return the currently tracked opacity for a label.
877///
878/// Reads from `HostState.window_opacities` — reflects the last value applied
879/// via `set_window_opacity`, not the Win32 layer. Used by the frontend to
880/// restore opacity on window init without an extra IPC round-trip.
881pub fn get_window_opacity(
882    state: &Arc<AppState>,
883    args: &serde_json::Value,
884) -> Result<serde_json::Value, String> {
885    let label = args
886        .get("label")
887        .and_then(|v| v.as_str())
888        .unwrap_or("main");
889    let opacity = state
890        .host_state
891        .lock()
892        .window_opacities
893        .get(label)
894        .copied()
895        .unwrap_or(1.0);
896    Ok(serde_json::json!(opacity))
897}