agentmux_cef/lib.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// AgentMux Host — Library entry point.
5//
6// This cdylib is the Windows DLL wrapper sandbox target (Phase 3, issue #1374).
7// `bootstrap.exe` (renamed `agentmux-cef.exe`) loads this DLL and calls
8// `RunWinMain` with a pre-initialized `cef_sandbox_info_t *` pointer, which we
9// forward into `run()`.
10//
11// On non-Windows or sandbox-off builds, only the `[[bin]]` target is used;
12// this file must still compile on those platforms.
13
14mod app;
15mod browser_api;
16mod browser_panes;
17mod client;
18mod commands;
19mod dev_authfile;
20mod drag_stash;
21mod events;
22mod ipc;
23mod launcher_event_bridge;
24mod launcher_ipc;
25mod parent_process;
26mod srv_event_bridge;
27mod srv_ipc;
28mod memory_heartbeat;
29mod memory_pressure;
30mod browser_pane;
31#[cfg(target_os = "windows")]
32mod floating_pane;
33mod reducer;
34mod saga_dispatch;
35mod sidecar;
36mod state;
37mod ui_tasks;
38mod wrr;
39#[cfg(target_os = "macos")]
40mod macos_menu;
41
42use std::sync::Arc;
43
44use cef::*;
45
46/// Suppress the Windows "Application Error" / WER crash dialog so an unhandled
47/// fault (a Chromium `LOG(FATAL)`, an `abort()`, a breakpoint) terminates the
48/// process immediately instead of wedging it behind a modal the user must
49/// dismiss. While that dialog is up the process is frozen and cannot be
50/// auto-recovered. No-op off Windows. Spec:
51/// docs/specs/SPEC_SERVICE_SUPERVISION_AND_RECOVERY_2026_05_20.md.
52#[cfg(target_os = "windows")]
53fn suppress_os_crash_dialogs() {
54 use windows_sys::Win32::System::Diagnostics::Debug::{SetErrorMode, SEM_FAILCRITICALERRORS};
55 use windows_sys::Win32::System::ErrorReporting::{WerSetFlags, WER_FAULT_REPORTING_NO_UI};
56 // Process-wide; also covers the CEF subprocesses.
57 unsafe {
58 // Suppress the WER crash-dialog UI WITHOUT disabling WER itself —
59 // SEM_NOGPFAULTERRORBOX would also kill WER/LocalDumps crash-dump
60 // collection, the postmortem diagnostics this stability work needs.
61 // WER_FAULT_REPORTING_NO_UI is the documented "no UI, keep
62 // reports" path.
63 let _ = WerSetFlags(WER_FAULT_REPORTING_NO_UI);
64 // SEM_FAILCRITICALERRORS suppresses the critical-error handler
65 // (e.g. "no disk in drive" popups) — unrelated to crash reporting.
66 SetErrorMode(SEM_FAILCRITICALERRORS);
67 }
68}
69
70#[cfg(not(target_os = "windows"))]
71fn suppress_os_crash_dialogs() {}
72
73/// Resolve CEF's `browser_subprocess_path` (the executable CEF spawns for
74/// renderer / GPU / utility processes).
75///
76/// On a packaged macOS `.app`, return the dedicated `AgentMux Helper`
77/// executable inside `Contents/Frameworks/AgentMux Helper.app` — re-execing
78/// the main bundle binary for subprocesses is rejected by the macOS process
79/// model (every child would inherit the main bundle's identity), which makes
80/// the renderers crash-loop. When no Helper.app is present (dev: a bare binary
81/// with no bundle) fall back to the current exe — self-reexec works there.
82/// Windows/Linux always use the current exe (self-reexec is fine off-bundle).
83#[cfg(target_os = "macos")]
84fn resolve_browser_subprocess_path() -> String {
85 let exe = std::env::current_exe().unwrap();
86 // exe = AgentMux.app/Contents/MacOS/agentmux-cef
87 // → AgentMux.app/Contents/Frameworks/AgentMux Helper.app/Contents/MacOS/AgentMux Helper
88 if let Some(contents) = exe.parent().and_then(|macos| macos.parent()) {
89 let helper = contents
90 .join("Frameworks")
91 .join("AgentMux Helper.app")
92 .join("Contents")
93 .join("MacOS")
94 .join("AgentMux Helper");
95 if helper.exists() {
96 return helper.to_string_lossy().into_owned();
97 }
98 }
99 exe.to_string_lossy().into_owned()
100}
101
102#[cfg(not(target_os = "macos"))]
103fn resolve_browser_subprocess_path() -> String {
104 std::env::current_exe()
105 .ok()
106 .and_then(|p| p.to_str().map(|s| s.to_owned()))
107 .unwrap_or_default()
108}
109
110/// True when a launcher is THIS host's actual parent process this run.
111///
112/// The launcher (which spawns the host directly) stamps
113/// `AGENTMUX_LAUNCHER_PID` with its own pid; since we are its direct
114/// child, that pid equals our `getppid()`. This distinguishes a fresh,
115/// authoritative launcher hand-off from a STALE `AGENTMUX_BACKEND_WS`
116/// inherited down the environment from a parent agentmux pane (whose
117/// launcher pid will not match our real parent). Used only to relax the
118/// dev-build "ignore the env hand-off" rule for the macOS/Linux Phase 1
119/// launcher dev integration.
120///
121/// Unix-only — on other platforms it returns `false`, leaving the
122/// existing dev-build behavior unchanged.
123#[cfg(unix)]
124fn launcher_is_genuine_parent() -> bool {
125 match std::env::var("AGENTMUX_LAUNCHER_PID")
126 .ok()
127 .and_then(|s| s.trim().parse::<i32>().ok())
128 {
129 // SAFETY: getppid() takes no arguments, touches no memory, and is
130 // documented as always succeeding.
131 Some(pid) => pid == unsafe { libc::getppid() },
132 None => false,
133 }
134}
135
136#[cfg(not(unix))]
137fn launcher_is_genuine_parent() -> bool {
138 false
139}
140
141/// Run the AgentMux host.
142///
143/// `windows_sandbox_info` is the `cef_sandbox_info_t *` created by
144/// `bootstrap.exe` before loading this DLL (Windows + sandbox feature), or
145/// `null_mut()` on all other platforms / sandbox-off builds.
146pub fn run(windows_sandbox_info: *mut std::ffi::c_void) -> i32 {
147 // Phase 0 (service supervision & recovery): suppress the Windows crash
148 // modal so a fault terminates the process immediately instead of freezing
149 // it behind an "Application Error" dialog. Must be the first statement —
150 // set before anything can fault.
151 suppress_os_crash_dialogs();
152
153 // Set the DLL search path so CEF's runtime LoadLibrary calls (chrome_elf,
154 // libEGL, libGLESv2, d3dcompiler_47, …) resolve against the directory that
155 // actually holds libcef.dll. Two layouts exist:
156 //
157 // Portable / installed: <root>/runtime/host.exe + libcef.dll alongside.
158 // The launcher (agentmux-launcher) already sets
159 // the path to <root>/runtime/ before spawning us;
160 // this block is a no-op safety net for that mode.
161 //
162 // Dev (`task dev`): dist/cef-dev/agentmux-cef.exe + libcef.dll
163 // alongside (flat layout). Taskfile launches the
164 // host directly with no launcher, so nothing else
165 // has set the DLL path. Without it, CEF's internal
166 // LoadLibrary chain can fail and `cef::initialize`
167 // returns 0 — the empty-chrome_debug.log mode.
168 //
169 // Fall back to the host's own directory whenever a runtime/ subdir isn't
170 // present. Idempotent in portable mode (launcher set it first), correct
171 // in dev mode.
172 #[cfg(target_os = "windows")]
173 {
174 if let Ok(exe) = std::env::current_exe() {
175 if let Some(dir) = exe.parent() {
176 let runtime_dir = dir.join("runtime");
177 let dll_dir = if runtime_dir.exists() {
178 runtime_dir
179 } else {
180 dir.to_path_buf()
181 };
182 unsafe {
183 use std::os::windows::ffi::OsStrExt;
184 let wide: Vec<u16> = dll_dir.as_os_str().encode_wide().chain(Some(0)).collect();
185 windows_sys::Win32::System::LibraryLoader::SetDllDirectoryW(wide.as_ptr());
186 }
187 }
188 }
189 }
190
191 // Tracing is initialized after the subprocess check below — browser process
192 // gets dual file+stderr output; subprocesses exit before tracing is needed.
193
194 // Escape hatch — read once here so both macOS subprocess sandbox init
195 // (below) and the browser-process Settings construction share the same value.
196 let force_no_sandbox = std::env::var("AGENTMUX_UNSAFE_NOSANDBOX")
197 .map(|v| v.trim() == "1")
198 .unwrap_or(false);
199
200 // macOS sandbox availability: probe for libcef_sandbox.dylib to detect
201 // whether we are running inside a packaged .app bundle. Two uses:
202 // 1. Seatbelt init (subprocess only) — skip if dylib not reachable.
203 // 2. no_sandbox flag to CefInitialize — must be 1 when not in bundle,
204 // otherwise CEF kills GPU/network/renderer with a sandbox error.
205 //
206 // The path is relative to current_exe().parent() and differs by role
207 // because the Host and Helper processes live at different depths:
208 //
209 // Host — Contents/MacOS/AgentMux
210 // → ../Frameworks/Chromium Embedded Framework.framework/…
211 //
212 // Helper — Contents/Frameworks/AgentMux Helper.app/Contents/MacOS/AgentMux Helper
213 // → ../../../Chromium Embedded Framework.framework/…
214 //
215 // In task dev neither path exists (flat dist/cef-dev/ tree), so the probe
216 // returns false for both roles → sandbox skipped, no crash.
217 #[cfg(all(target_os = "macos", feature = "sandbox"))]
218 let macos_sandbox_available = {
219 let is_subprocess = std::env::args().any(|a| a.starts_with("--type="));
220 let rel: &str = if is_subprocess {
221 "../../../Chromium Embedded Framework.framework/Libraries/libcef_sandbox.dylib"
222 } else {
223 "../Frameworks/Chromium Embedded Framework.framework/Libraries/libcef_sandbox.dylib"
224 };
225 std::env::current_exe()
226 .ok()
227 .and_then(|p| p.parent().map(|d| d.join(rel)))
228 .map(|p| p.exists())
229 .unwrap_or(false)
230 };
231
232 // Non-macOS / sandbox-off: define macos_sandbox_available as a no-op
233 // placeholder so the no_sandbox computation below compiles on all targets.
234 // On those targets cfg!(all(target_os="macos",feature="sandbox")) is false,
235 // so the `&& !macos_sandbox_available` branch is never reached at runtime.
236 #[cfg(not(all(target_os = "macos", feature = "sandbox")))]
237 let macos_sandbox_available = true; // irrelevant — guarded by cfg!() in no_sandbox
238
239 // macOS: initialize Seatbelt sandbox context BEFORE the CEF framework is
240 // loaded. This is the order required by CEF (cefsimple/process_helper_mac.cc):
241 // 1. cef_sandbox_initialize ← here, before dlopen
242 // 2. LoadInHelper / _library ← CEF framework loaded within the sandbox
243 // 3. CefExecuteProcess
244 // Subprocess mode is detected from raw argv (--type=…) because cef::Args
245 // cannot be parsed until after the framework is loaded. Browser process
246 // skips this entirely — the host process is unsandboxed by design.
247 #[cfg(all(target_os = "macos", feature = "sandbox"))]
248 let _macos_sandbox = if std::env::args().any(|a| a.starts_with("--type="))
249 && !force_no_sandbox
250 && macos_sandbox_available
251 {
252 let raw = cef::args::Args::new();
253 let mut s = cef::sandbox::Sandbox::new();
254 s.initialize(raw.as_main_args());
255 Some(s)
256 } else {
257 None
258 };
259
260 // macOS: load the CEF framework library explicitly.
261 // For subprocesses this load happens within the Seatbelt policy established above.
262 #[cfg(target_os = "macos")]
263 let _library = {
264 let exe = std::env::current_exe().unwrap();
265 // Inside a packaged .app, renderer/GPU/utility subprocesses run as the
266 // bundled "AgentMux Helper" at
267 // …/Contents/Frameworks/AgentMux Helper.app/Contents/MacOS/AgentMux Helper
268 // — 4 levels below the framework — so it must resolve the framework via
269 // the deeper `../../../../Frameworks/…` path (helper=true). The main
270 // host (Contents/MacOS/) and the bare dev binary use `../Frameworks/…`
271 // (helper=false). Detect the helper by its exe path; the main bundle
272 // binary is never under Contents/Frameworks/. See
273 // docs/specs/SPEC_MACOS_PACKAGING_2026_05_30.md.
274 let is_helper = exe.to_string_lossy().contains(".app/Contents/Frameworks/");
275 let loader = library_loader::LibraryLoader::new(&exe, is_helper);
276 assert!(loader.load(), "Failed to load CEF framework");
277 loader
278 };
279
280 // Initialize the CEF API hash for version verification.
281 let _ = api_hash(sys::CEF_API_VERSION_LAST, 0);
282
283 // Parse command-line arguments.
284 let args = cef::args::Args::new();
285 let Some(cmd_line) = args.as_cmd_line() else {
286 eprintln!("agentmux-cef: Failed to parse command line arguments");
287 std::process::exit(1);
288 };
289
290 // Detect subprocess mode: CEF injects --type=renderer|gpu-process|utility
291 // for child processes. If --type is present, this is a subprocess.
292 let type_switch = CefString::from("type");
293 let is_browser_process = cmd_line.has_switch(Some(&type_switch)) != 1;
294
295 // Execute subprocess if applicable (exits here for non-browser processes).
296 let ret = execute_process(
297 Some(args.as_main_args()),
298 None, // App can be None for subprocess
299 windows_sandbox_info as *mut u8,
300 );
301
302 if is_browser_process {
303 // Browser process: execute_process returns -1, we continue with initialization.
304 assert_eq!(ret, -1, "execute_process should return -1 for browser process");
305 } else {
306 // Subprocess: execute_process returns the exit code.
307 let process_type = CefString::from(&cmd_line.switch_value(Some(&type_switch)));
308 eprintln!("agentmux-cef: subprocess exiting: type={}", process_type);
309 assert!(ret >= 0, "execute_process failed for subprocess");
310 std::process::exit(ret);
311 }
312
313 // -----------------------------------------------------------------------
314 // Browser process initialization
315 // -----------------------------------------------------------------------
316
317 // Set the Application User Model ID before any UI is created. This lets
318 // Windows group our windows under one pinned identity and is required for
319 // the `DeleteTab` + per-HWND AppID treatment used by the full-instance /
320 // sub-window model (see docs/specs/SPEC_MULTIWINDOW_TASKBAR_GROUPING.md).
321 // Use a VERSION-STABLE ID — never embed the patch number or pinning forks.
322 #[cfg(target_os = "windows")]
323 unsafe {
324 use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
325 let aumid: Vec<u16> = "AgentMuxCorp.AgentMux\0".encode_utf16().collect();
326 let _ = SetCurrentProcessExplicitAppUserModelID(aumid.as_ptr());
327 }
328
329 let version = env!("CARGO_PKG_VERSION");
330
331 // Read paths + mode from the launcher-injected env vars. Two
332 // reachable configurations:
333 // a) Launcher-managed startup → env vars present, from_env()
334 // returns Some.
335 // b) Standalone `task dev` → env absent. We re-derive via
336 // `RuntimeMode::current` + `DataPaths::resolve` (symmetric
337 // with sidecar.rs::spawn_backend's fallback so they agree on
338 // the disk layout).
339 let host_exe_dir = std::env::current_exe()
340 .ok()
341 .and_then(|p| p.parent().map(|d| d.to_path_buf()))
342 .unwrap_or_default();
343 // Dev builds NEVER inherit AGENTMUX_* env vars from a parent process.
344 // `task dev` is routinely launched from inside an AgentMux terminal
345 // pane, which means the child host inherits the parent instance's
346 // AGENTMUX_DATA_DIR pointing at the parent's version-isolated dir.
347 // Without this guard the dev build would resolve its data dir to the
348 // running portable's path and trip CEF's process-singleton lock,
349 // routing every "open" back to the existing window — the user would
350 // never see the dev code run. Path-based detection is authoritative
351 // for dev builds; for installed/portable we still honor the
352 // launcher-provided env (it's the launcher's job to publish them).
353 let common_paths = if agentmux_common::is_dev_build_exe(&host_exe_dir) {
354 let mode = agentmux_common::RuntimeMode::current_path_only(&host_exe_dir);
355 // resolve_path_only mirrors current_path_only's env-isolation:
356 // ignore inherited AGENTMUX_CHANNEL so a dev host launched from
357 // inside a parent agentmux instance doesn't redirect into the
358 // parent's channel (would trip the channel single-instance lock).
359 agentmux_common::DataPaths::resolve_path_only(version, &mode).ok()
360 } else {
361 agentmux_common::DataPaths::from_env().or_else(|| {
362 let mode = agentmux_common::RuntimeMode::current(&host_exe_dir);
363 agentmux_common::DataPaths::resolve(version, &mode).ok()
364 })
365 };
366 let is_dev = match &common_paths {
367 Some(p) => matches!(p.mode, agentmux_common::RuntimeMode::Dev { .. }),
368 None => false,
369 };
370
371 let (data_dir, log_dir) = match &common_paths {
372 Some(p) => (p.cef_cache_dir.clone(), p.logs_dir.clone()),
373 None => {
374 // Both env-read AND fallback resolution failed (no home
375 // dir on disk, or platform unsupported). Use a degraded
376 // path so log init at least works; the runtime-startup
377 // check below will surface the underlying error.
378 (
379 std::path::PathBuf::from("."),
380 dirs::home_dir()
381 .unwrap_or_default()
382 .join(".agentmux")
383 .join("logs"),
384 )
385 }
386 };
387 std::fs::create_dir_all(&data_dir).ok();
388
389 // Initialize dual-output tracing: rolling log file + stderr.
390 // The log file guard must live for the entire process to ensure flushing.
391 let _log_guard = init_logging(&log_dir);
392
393 tracing::info!(
394 version,
395 runtime_mode = ?common_paths.as_ref().map(|p| p.mode.to_env_string()),
396 data_dir = %data_dir.display(),
397 log_dir = %log_dir.display(),
398 "Initializing CEF browser process"
399 );
400
401 // macOS 26 Tahoe compat: CEF 146 calls private NSApplication selectors
402 // (e.g. `isHandlingSendEvent`, `isSendingEvent`) during NSDraggingSession
403 // setup. Apple removed them in macOS 26, so the calls go through
404 // `___forwarding___`, find nothing, and `objc_exception_throw` fires —
405 // AppKit's default uncaught-exception handler then calls `_objc_terminate`
406 // and the host dies (EXC_BREAKPOINT / SIGTRAP on CrBrowserMain). Inject
407 // `+[NSApplication resolveInstanceMethod:]` to add typed stubs *before*
408 // the forwarding machinery is entered. Must run before `cef::initialize`.
409 // See docs/specs/SPEC_MACOS_TEAROFF_STABILITY_2026_05_29.md and PR #403.
410 #[cfg(target_os = "macos")]
411 unsafe { patch_nsapp_unrecognized_selector() };
412
413 // macOS tear-off polish: suppress AppKit's native drag slide-back
414 // ("poof" / fly-back-to-origin) animation. When a pane/tab is torn
415 // off, the pointer is released OUTSIDE any DOM drop target (the new
416 // floating window is created on mouseup), so blink hands AppKit
417 // NSDragOperationNone and AppKit animates the drag image sliding
418 // back into the source window — the very "rejection" animation the
419 // tear-off is trying to avoid. `preventUnhandled` (PR #1186) only
420 // covers in-document drops, not this out-of-window case. Disable the
421 // session-level animation flag globally; we never want a
422 // drop-rejected slide-back anywhere in the app.
423 #[cfg(target_os = "macos")]
424 unsafe { disable_macos_drag_slideback() };
425
426 // Name the app early (sets CFBundleName on the main bundle, which AppKit
427 // may read when it first builds a menu). Also re-run post-init below, since
428 // Chromium can reset the process name during cef::initialize.
429 #[cfg(target_os = "macos")]
430 unsafe { set_macos_app_display_name() };
431
432 // Phase B.6 (post-fix): the named-pipe bind in the launcher is
433 // the AUTHORITATIVE single-instance lock — a second launcher
434 // hits ERROR_ACCESS_DENIED and never reaches the host. We still
435 // publish `<launcher-shared-data-dir>/ipc-port` (port:token) so
436 // the second launcher can FORWARD an `open_new_window` request
437 // to the existing instance over HTTP and exit silently — the
438 // legacy forwarding UX users expect when double-clicking the
439 // exe twice. The pipe-bind-first ordering closes the stale-state
440 // defect (gap #8 in
441 // specs/ANALYSIS_WINDOW_PROCESS_STATE_INVENTORY_2026_04_27.md):
442 // a stale ipc-port file from a hard crash is irrelevant on the
443 // FIRST-instance path because pipe-bind succeeds and the file is
444 // overwritten; on the SECOND-instance path the live first
445 // instance wrote a fresh port:token, so forwarding lands.
446 //
447 // CRITICAL: write the port file at the LAUNCHER-shared data dir
448 // (`AGENTMUX_DATA_DIR`, == `paths.data_dir` in the launcher), NOT
449 // the host-local CEF cache dir (`<portable>/data/cef/`). The two
450 // diverge in portable mode (cef cache is one level deeper) and
451 // the launcher's `forward_open_new_window` reads the launcher-
452 // shared path. Falls back to the cef cache dir only when the env
453 // is unset (`task dev` mode without launcher), where forwarding
454 // wouldn't be wired anyway.
455 // Dev builds inherit AGENTMUX_DATA_DIR from the parent pane they were
456 // launched from. Writing ipc-port there would overwrite the parent
457 // instance's port:token and break its single-instance forwarding.
458 // In dev mode there is no launcher so port forwarding isn't wired
459 // anyway — use the dev data dir directly.
460 let port_file_dir = if agentmux_common::is_dev_build_exe(&host_exe_dir) {
461 data_dir.clone()
462 } else {
463 std::env::var_os("AGENTMUX_DATA_DIR")
464 .map(std::path::PathBuf::from)
465 .unwrap_or_else(|| data_dir.clone())
466 };
467 let _ = std::fs::create_dir_all(&port_file_dir);
468 // Use a version-scoped filename so two concurrent release versions
469 // don't overwrite each other's port file (codex P1 on #1227).
470 // AGENTMUX_IPC_HASH is set by the launcher to hash(data_dir, version).
471 let port_file_name = std::env::var("AGENTMUX_IPC_HASH")
472 .ok()
473 .filter(|h| !h.is_empty())
474 .map(|h| format!("ipc-port-{}", h))
475 .unwrap_or_else(|| "ipc-port".to_string());
476 let port_file = port_file_dir.join(&port_file_name);
477
478 // Create shared application state.
479 let app_state = Arc::new(state::AppState::default());
480
481 // Start tokio runtime for async operations (IPC server, sidecar management).
482 let runtime = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
483
484 // Install the runtime Handle into browser_pane::auth so the
485 // CEF `get_auth_credentials` callback (which runs on CEF's IO
486 // thread) can spawn the parked-auth TTL timer. A bare
487 // `tokio::spawn` there would panic with "there is no reactor
488 // running" because that thread has no `Handle::current()`.
489 browser_pane::auth::set_runtime_handle(runtime.handle().clone());
490
491 // Start the IPC HTTP server and get the assigned port.
492 let ipc_port = runtime.block_on(ipc::start_ipc_server(app_state.clone()));
493 *app_state.ipc_port.lock() = ipc_port;
494
495 tracing::info!("IPC server started on port {}", ipc_port);
496
497 // Phase B.2: connect to launcher's named-pipe IPC (if launcher
498 // is in the loop) so the launcher can route Commands and Events
499 // through us. The handle is held in main scope for the host's
500 // lifetime — dropping it closes the pipe (logged by launcher).
501 // Failure to connect is non-fatal in B.2 (host can still run);
502 // B.5+ will tighten when the host depends on IPC for state.
503 //
504 // Env-isolation guard: a dev build inheriting
505 // `AGENTMUX_LAUNCHER_PIPE` from a parent AgentMux pane (e.g. a
506 // shell inside an active pane that re-invokes the host directly)
507 // would otherwise connect to that parent's launcher pipe and
508 // route its host events into the parent's launcher state.
509 //
510 // Discriminator: connect when our parent process IS the launcher
511 // (production portable, installed build, OR post-#SPEC_LAUNCHER_DEV_INTEGRATION
512 // `task dev` which spawns the host via the launcher). Skip when
513 // it isn't.
514 //
515 // Older path-only guard (`is_dev_build_exe`) over-fired in dev
516 // mode after launcher integration shipped — see
517 // docs/specs/SPEC_DEV_MODE_LAUNCHER_IPC_2026_05_16.md.
518 let parent_is_launcher = parent_process::parent_is_agentmux_launcher();
519 let should_connect_launcher = match parent_is_launcher {
520 Some(true) => true,
521 Some(false) => false,
522 // Parent detection failed — fall back to the path-based guard
523 // so production builds still connect (they would otherwise
524 // silently lose the launcher IPC) and dev builds still skip.
525 None => !agentmux_common::is_dev_build_exe(&host_exe_dir),
526 };
527 let _launcher_ipc = if should_connect_launcher {
528 runtime.block_on(launcher_ipc::connect_to_launcher(app_state.clone()))
529 } else {
530 None
531 };
532
533 // Phase E.2c.5a — connect to the srv reducer's pipe. Forwards
534 // srv events (workspace / tab / block lifecycle) to every
535 // top-level renderer via the JS bridge. Renderer-side handler
536 // (`window.__agentmux_srv_event`) lands in E.2c.5b. Non-fatal
537 // if absent: `AGENTMUX_SRV_PIPE_PATH` is only set on the srv
538 // child by the launcher (`agentmux-launcher/src/srv_spawner.rs`),
539 // not on the host spawn — so today the host never has the env
540 // var and `connect_to_srv` short-circuits to None at
541 // `srv_ipc.rs:62-68`. Path-based dev guard is the right gate
542 // for this branch; restoring full srv-IPC parity in dev needs
543 // the launcher to propagate the env var to the host first.
544 // See spec §11 of SPEC_DEV_MODE_LAUNCHER_IPC_2026_05_16.md.
545 let _srv_ipc = if agentmux_common::is_dev_build_exe(&host_exe_dir) {
546 None
547 } else {
548 runtime.block_on(srv_ipc::connect_to_srv(app_state.clone()))
549 };
550
551 // Phase B.1: if launcher already spawned srv (the normal portable
552 // / installed path post-PR-#570 + B.1), populate state from the
553 // env vars launcher set — no need to re-spawn srv. Falls back to
554 // spawn_backend() ONLY when env vars are absent (`task dev` mode
555 // where the host runs without the launcher).
556 //
557 // Spawn the backend sidecar SYNCHRONOUSLY — block until it
558 // signals ready (AGENTMUXSRV-ESTART) before creating the browser
559 // window. This eliminates the race condition where CEF loads the
560 // frontend before the backend is available, which causes a "raw
561 // browser" appearance on slow machines or first launch.
562 let backend_ready = runtime.block_on(async {
563 // Dev builds inherit AGENTMUX_BACKEND_WS from the parent pane.
564 // Consuming it would connect to the parent's srv instead of
565 // spawning our own, so the dev frontend runs against the wrong
566 // (parent's) backend and no dev-version srv is ever started.
567 //
568 // EXCEPTION (macOS/Linux Phase 1 launcher dev integration,
569 // SPEC_LAUNCHER_MACOS_DEV_INTEGRATION_2026_05_30): when a launcher
570 // is our GENUINE parent this run (it stamped AGENTMUX_LAUNCHER_PID
571 // with its pid == our getppid), the env it set is fresh + ours —
572 // adopt its launcher-owned srv instead of double-spawning. The
573 // getppid match can't be satisfied by a value merely inherited
574 // down the env from a parent pane, so `task dev:standalone`
575 // (direct host invoke) still spawns its own srv.
576 let launcher_provided = if agentmux_common::is_dev_build_exe(&host_exe_dir)
577 && !launcher_is_genuine_parent()
578 {
579 None
580 } else {
581 sidecar::use_launcher_endpoints(&app_state)
582 };
583 let result = match launcher_provided {
584 Some(Ok(r)) => {
585 tracing::info!(
586 "Using launcher-provided backend endpoints: ws={} web={} pid={}",
587 r.ws_endpoint,
588 r.web_endpoint,
589 r.instance_id
590 );
591 Ok(r)
592 }
593 Some(Err(e)) => {
594 tracing::error!(
595 "Launcher set AGENTMUX_BACKEND_WS but env was malformed: {} — refusing to fall back to spawn_backend (would fight launcher's srv)",
596 e
597 );
598 Err(e)
599 }
600 None => {
601 tracing::info!("No launcher-provided backend env (dev mode) — spawning srv ourselves");
602 sidecar::spawn_backend(&app_state).await
603 }
604 };
605 match result {
606 Ok(result) => {
607 {
608 let mut endpoints = app_state.backend_endpoints.lock();
609 endpoints.ws_endpoint = result.ws_endpoint.clone();
610 endpoints.web_endpoint = result.web_endpoint.clone();
611 }
612 tracing::info!(
613 "Backend ready: ws={} web={}",
614 result.ws_endpoint,
615 result.web_endpoint
616 );
617 true
618 }
619 Err(e) => {
620 tracing::error!("Failed to set up backend: {}", e);
621 false
622 }
623 }
624 });
625
626 if !backend_ready {
627 tracing::error!("Backend failed to start — exiting");
628 std::process::exit(1);
629 }
630
631 // Dev-only: write authkey.dev so external test harnesses can call
632 // the service API without polling logs or driving the UI. Gate is
633 // Write authkey.dev for ALL runtime modes (dev, portable, installed).
634 // The file lets bench-term-echo.mjs and the PowerShell test harnesses
635 // discover the running instance without manual --ws-url / --auth-key flags.
636 // Security: the WS server is loopback-only; any same-user process already
637 // has equivalent TCP access. See SPEC_TEST_API_ACCESS.md §3 and
638 // SPEC_BENCHMARK_PORTABLE_DISCOVERY_2026_05_20.md for rationale.
639 {
640 let endpoints = app_state.backend_endpoints.lock().clone();
641 let auth_key = app_state.auth_key.lock().clone();
642 let ipc_token = app_state.ipc_token.clone();
643 let data_dir_str = app_state
644 .version_data_dir
645 .lock()
646 .clone()
647 .unwrap_or_default();
648 let data_dir_path = std::path::PathBuf::from(&data_dir_str);
649 let ipc_endpoint = format!("127.0.0.1:{}", ipc_port);
650 let instance = format!("v{}", env!("CARGO_PKG_VERSION"));
651 let host_pid = std::process::id();
652 match dev_authfile::write_dev_auth_file(
653 &data_dir_path,
654 &auth_key,
655 &endpoints.web_endpoint,
656 &endpoints.ws_endpoint,
657 &ipc_endpoint,
658 &ipc_token,
659 &instance,
660 host_pid,
661 ) {
662 Ok(p) => tracing::info!("Wrote authkey file: {}", p.display()),
663 Err(e) => tracing::warn!("Failed to write authkey file: {}", e),
664 }
665 }
666
667 // Create the App handler with state.
668 let mut cef_app = app::AgentMuxApp::new(app_state.clone(), ipc_port);
669
670 // Resolve resource directories for portable layout. In portable
671 // mode the CEF host is IN runtime/, so resources are flat
672 // alongside it. In dev mode they are also flat in dist/cef-dev/.
673 // Reuses `host_exe_dir` from the startup mode-detection block.
674 //
675 // macOS is the exception: Chromium ships icudtl.dat + the .pak
676 // files + locales inside the framework bundle's `Resources/`
677 // directory, not flat next to the binary. cef-rs's LibraryLoader
678 // already finds the framework via `../Frameworks/...`; we point
679 // CefSettings at the same place so Chromium's icu_util loader,
680 // pack loader, and locale loader resolve correctly. See
681 // docs/specs/SPEC_MACOS_CEF_FRAMEWORK_BUNDLING_2026_05_28.md.
682 let runtime_dir = host_exe_dir.join("runtime");
683 let base_dir = if runtime_dir.exists() {
684 runtime_dir
685 } else {
686 host_exe_dir.clone()
687 };
688 #[cfg(not(target_os = "macos"))]
689 let (resources_path, locales_path) = (base_dir.clone(), base_dir.join("locales"));
690 #[cfg(target_os = "macos")]
691 let (framework_path, resources_path, locales_path) = {
692 let framework = base_dir
693 .join("..")
694 .join("Frameworks")
695 .join("Chromium Embedded Framework.framework");
696 let resources = framework.join("Resources");
697 (framework, resources.clone(), resources)
698 };
699 let resources_dir = CefString::from(resources_path.to_str().unwrap_or(""));
700 let locales_dir = CefString::from(locales_path.to_str().unwrap_or(""));
701 #[cfg(target_os = "macos")]
702 let framework_dir = CefString::from(framework_path.to_str().unwrap_or(""));
703
704 // Reuse data_dir from single-instance check as CEF cache path.
705 // Remove stale lockfile from a previous killed run.
706 let lockfile = data_dir.join("lockfile");
707 if lockfile.exists() {
708 tracing::warn!("Removing stale CEF lockfile: {}", lockfile.display());
709 let _ = std::fs::remove_file(&lockfile);
710 }
711 tracing::info!("CEF cache dir: {}", data_dir.display());
712 let cache_dir = CefString::from(data_dir.to_str().unwrap_or(""));
713 // Expose root_cache_path so per-window RequestContexts root their cache_path
714 // UNDER it — CEF requires a descendant of root_cache_path, else it falls back
715 // to in-memory storage. SPEC_CEF_LOG_ROBUSTNESS_2026_06_20.md §1.
716 *app_state.cef_cache_dir.lock() = Some(data_dir.to_string_lossy().to_string());
717
718 // Configure CEF settings.
719 // Pick a FREE remote-debugging port instead of a fixed one: AgentMux runs
720 // many instances in parallel (isolation I1–I6), so a hardcoded port collides
721 // (WSAEADDRINUSE / 0x2740) and the 2nd+ instance gets no DevTools server →
722 // the browser DOM API (`/agentmux/browser/*`) can't connect. Prefer the
723 // conventional port (9223 dev / 9222 release) for muscle memory; fall back to
724 // an OS-assigned free port. Store the ACTUAL port so `browser_api` targets
725 // it. SPEC_CEF_LOG_ROBUSTNESS_2026_06_20.md §2.
726 let preferred: u16 = if is_dev { 9223 } else { 9222 };
727 let debug_port: u16 = {
728 use std::net::TcpListener;
729 if TcpListener::bind(("127.0.0.1", preferred)).is_ok() {
730 preferred
731 } else {
732 TcpListener::bind(("127.0.0.1", 0))
733 .and_then(|l| l.local_addr())
734 .map(|a| a.port())
735 .unwrap_or(preferred)
736 }
737 };
738 *app_state.debug_port.lock() = debug_port;
739 tracing::info!("CEF remote-debugging port: {} (preferred {})", debug_port, preferred);
740
741 // Route CEF's internal Chromium logging into our log dir alongside the
742 // tracing-subscriber file. Without this, init failures leave an empty
743 // chrome_debug.log in the cache dir and we have nothing to read. INFO is
744 // verbose enough to expose load-library / resource problems but quiet
745 // enough not to swamp the file in normal operation.
746 let cef_log_path = log_dir.join("cef-debug.log");
747 let cef_log_file = CefString::from(cef_log_path.to_str().unwrap_or(""));
748
749 if force_no_sandbox {
750 tracing::warn!(
751 "AGENTMUX_UNSAFE_NOSANDBOX=1: renderer sandbox disabled. \
752 Only use in environments where namespace/Seatbelt sandbox is known-incompatible."
753 );
754 }
755
756 // Sandbox active on macOS (Seatbelt via libcef_sandbox.dylib), Linux
757 // (kernel namespace isolation), and Windows (bootstrap.exe DLL wrapper,
758 // Phase 3) when built with `--features sandbox` (the default).
759 // Escape hatch: AGENTMUX_UNSAFE_NOSANDBOX=1 disables on all platforms.
760 let no_sandbox: i32 = if cfg!(not(feature = "sandbox"))
761 || force_no_sandbox
762 || cfg!(all(target_os = "macos", feature = "sandbox")) && !macos_sandbox_available
763 {
764 1
765 } else {
766 0
767 };
768
769 let settings = Settings {
770 no_sandbox,
771 // ARGB: alpha=0 → SK_AlphaTRANSPARENT → triggers the transparency
772 // cascade in the patched libcef.so (see cef commits b921ffe18 +
773 // 68e0dc668). The CSS layer's rgba(_,_,_,<1) body bg then composites
774 // with the desktop instead of being clamped to opaque white.
775 // Pair: BrowserSettings.background_color must also be 0 (app.rs).
776 // Pair: WindowDelegate must return is_frameless=true (already does
777 // for the main window).
778 // Spec: docs/research/cef-transparency-research-2026-05-10.md.
779 background_color: 0x00000000,
780 remote_debugging_port: debug_port as i32,
781 root_cache_path: cache_dir,
782 resources_dir_path: resources_dir,
783 locales_dir_path: locales_dir,
784 // macOS-only: tell CEF where the framework bundle lives so it can
785 // resolve icudtl.dat, *.pak, and helper-process binaries through
786 // NSBundle. Without this, Chromium's icu_util loader looks via
787 // [NSBundle mainBundle] (the host exe's pseudo-bundle) which has
788 // no Resources/ and fails with "icudtl.dat not found in bundle".
789 #[cfg(target_os = "macos")]
790 framework_dir_path: framework_dir,
791 log_file: cef_log_file,
792 log_severity: LogSeverity::INFO,
793 // CEF subprocess (renderer, GPU, utility) executable. On a packaged
794 // macOS .app this is the dedicated "AgentMux Helper" (distinct bundle
795 // id, LSUIElement) — re-execing the main bundle binary is rejected by
796 // the macOS process model and the renderers crash-loop. In dev (bare
797 // binary, no Helper.app) and on Windows/Linux it's the current exe
798 // (self-reexec, which works there). See
799 // docs/specs/SPEC_MACOS_PACKAGING_2026_05_30.md.
800 browser_subprocess_path: CefString::from(resolve_browser_subprocess_path().as_str()),
801 ..Default::default()
802 };
803
804 // Initialize CEF.
805 //
806 // CefInitialize returns 1 on success and 0 either on real init failure OR
807 // on "normal early exit" (process singleton, command-line forward, etc).
808 // We can only tell the two apart by calling cef_get_exit_code() and
809 // matching against cef_resultcode_t. Treat NORMAL_EXIT* codes as a clean
810 // exit; everything else is a real failure that we surface via panic.
811 //
812 // Common early-exit codes (cef_resultcode_t):
813 // 0 CEF_RESULT_CODE_NORMAL_EXIT
814 // 24 CEF_RESULT_CODE_NORMAL_EXIT_PROCESS_NOTIFIED ← singleton relaunch
815 // 36 CEF_RESULT_CODE_NORMAL_EXIT_PACK_EXTENSION_SUCCESS
816 // 38 CEF_RESULT_CODE_NORMAL_EXIT_AUTO_DE_ELEVATED
817 let init_result = initialize(
818 Some(args.as_main_args()),
819 Some(&settings),
820 Some(&mut cef_app),
821 windows_sandbox_info as *mut u8,
822 );
823 if init_result != 1 {
824 let exit_code = get_exit_code();
825 // Sidecar was spawned before cef_initialize(); std::process::exit()
826 // bypasses the normal shutdown block, so kill it here first.
827 {
828 let mut sidecar = app_state.sidecar_child.lock();
829 if let Some(ref mut child) = *sidecar {
830 tracing::info!("CEF early exit: killing backend sidecar before exit");
831 let _ = child.kill();
832 }
833 }
834 match exit_code {
835 0 | 24 | 36 | 38 => {
836 tracing::info!(
837 exit_code,
838 "CEF early exit (process singleton or similar) — exiting cleanly"
839 );
840 std::process::exit(0);
841 }
842 _ => {
843 tracing::error!(
844 exit_code,
845 "CEF initialization failed; see ~/.agentmux/logs/cef-debug.log for details"
846 );
847 // Surface a user-facing error instead of a silent splash-then-exit.
848 // The most common cause is a bundled CEF runtime whose version does
849 // not match the linked `cef` crate (e.g. a stale libcef.dll) — that
850 // path logs "Request for unsupported CEF API version NNNNN" to
851 // cef-debug.log and would otherwise just vanish after the splash.
852 // See docs/specs/SPEC_WINDOWS_CEF_BUNDLE_VERSION_INTEGRITY_2026_06_03.md.
853 #[cfg(target_os = "windows")]
854 {
855 use windows_sys::Win32::UI::WindowsAndMessaging::{
856 MessageBoxW, MB_ICONERROR, MB_OK,
857 };
858 let title: Vec<u16> =
859 "AgentMux — startup failed\0".encode_utf16().collect();
860 let body = format!(
861 "AgentMux couldn't start its browser engine (CEF init failed, code {}).\n\n\
862 This usually means the bundled browser runtime is incompatible with this \
863 build — for example a stale or mismatched libcef.dll from an incomplete build.\n\n\
864 Details were written to:\n %USERPROFILE%\\.agentmux\\logs\\cef-debug.log\n\n\
865 If you built this locally, run: task clean:cef && task build:host",
866 exit_code
867 );
868 let body_w: Vec<u16> =
869 body.encode_utf16().chain(std::iter::once(0)).collect();
870 // SAFETY: null parent HWND with valid NUL-terminated wide strings.
871 unsafe {
872 MessageBoxW(
873 std::ptr::null_mut(),
874 body_w.as_ptr(),
875 title.as_ptr(),
876 MB_OK | MB_ICONERROR,
877 );
878 }
879 }
880 std::process::exit(exit_code);
881 }
882 }
883 }
884
885 tracing::info!("CEF initialized, entering message loop");
886
887 // Claim a Dock tile, THEN paint the icon onto it. A bare Mach-O launched
888 // outside an `.app` bundle (both `task dev` direct-invoke and the launcher
889 // flat layout) defaults to a background/accessory activation policy — no
890 // Dock tile, no menu bar — so the AgentMux instance never shows in the
891 // taskbar and the icon set below has nothing to land on. Force
892 // NSApplicationActivationPolicyRegular first so the instance is a normal,
893 // Dock-visible app. macOS-only; no-op elsewhere. Order matters: policy
894 // before icon.
895 #[cfg(target_os = "macos")]
896 unsafe {
897 // Friendly Dock + app-menu name ("AgentMux DEV" in dev) instead of the
898 // raw process name "agentmux-cef". Done POST-init: Chromium overwrites
899 // the process name during cef::initialize, so a pre-init set didn't
900 // stick — set it here, right before the activation policy + our menu
901 // bar are established.
902 set_macos_app_display_name();
903 set_macos_activation_policy_regular();
904 set_macos_dock_icon();
905 // Layer 1 of SPEC_MACOS_ACCESSIBILITY_ROBUSTNESS_2026-06-03: govern
906 // Chromium's macOS accessibility activation so a window manager / KVM
907 // poking the AX tree (Magnet, Synergy, …) can't force the crash-prone
908 // web-content AX mode (CEF #3512). Must run after cef::initialize so
909 // the CEF NSApplication subclass (which owns the legacy AX setter)
910 // exists.
911 install_macos_accessibility_governor();
912 }
913
914 // Native macOS menu bar (File/Edit/View/Window/Help) — Phase 1 of
915 // SPEC_MACOS_NATIVE_MENU_BAR_2026-06-03. After cef::initialize (NSApplication
916 // exists) and after set_macos_app_display_name (the app-menu title follows
917 // the process name). Standard Edit/Window items route to the focused web
918 // view; custom items dispatch through the frontend command registry.
919 #[cfg(target_os = "macos")]
920 macos_menu::install_menu_bar(app_state.clone());
921
922 // Reopen handler — a plain re-launch / Finder/Dock double-click of the
923 // running app opens a new window (Windows-parity) instead of just focusing
924 // it. Installs an NSApplication delegate (`applicationShouldHandleReopen:`);
925 // a raw NSAppleEventManager handler was inert because CEF re-registers its
926 // own. After menu install, NSApplication exists.
927 // SPEC_MACOS_LAUNCH_COHERENCE_2026_06_18.md.
928 #[cfg(target_os = "macos")]
929 macos_menu::install_reopen_handler(app_state.clone());
930
931 // Start memory heartbeat — logs system/process memory stats every 20s.
932 // Provides forensic data if the process later crashes from OOM / VA
933 // exhaustion, and drives the debounced mem_pressure level + low-memory
934 // banner (SPEC_MEMORY_PRESSURE_SUPERVISION_2026_06_16 §5.A/§5.F).
935 memory_heartbeat::start(app_state.clone());
936
937 // Phase B.6 (post-fix): publish port:token AFTER CEF init so a
938 // second launcher only forwards `open_new_window` when we're
939 // actually ready to handle it. Single-instance enforcement is
940 // the launcher's named-pipe bind — this file is purely a
941 // forwarding hint.
942 let _ = std::fs::write(
943 &port_file,
944 format!("{}:{}", ipc_port, app_state.ipc_token),
945 );
946
947 // Phase B.9.1 (WRR) — install Win32 event hooks. Must come
948 // AFTER `connect_to_launcher` so the report_hwnd_* sync APIs
949 // have a live `COMMAND_TX` to push into; AFTER CEF init so
950 // any HWNDs CEF creates during initialize() are missed
951 // (acceptable — they predate the user's session and are
952 // accounted for by main-window startup paths). Idempotent;
953 // safe to call multiple times. State arg lets the callback
954 // peek `pending_window_creations` for `label_hint`.
955 wrr::install_hooks(app_state.clone());
956
957 // Run the CEF message loop. This blocks until quit_message_loop() is called
958 // (triggered when all browser windows are closed in client.rs).
959 run_message_loop();
960
961 tracing::info!("CEF message loop exited, shutting down");
962
963 // Kill the backend sidecar. (The launcher's Job Object also reaps it once
964 // we exit; kill explicitly for promptness.)
965 {
966 let mut sidecar = app_state.sidecar_child.lock();
967 if let Some(ref mut child) = *sidecar {
968 tracing::info!("Killing backend sidecar");
969 let _ = child.kill();
970 }
971 }
972
973 // Phase B.6 (post-fix): clean up the forwarding hint so a stale file doesn't
974 // survive a graceful exit. (Hard crashes leave it behind; harmless because
975 // pipe-bind on next launch is authoritative.)
976 let _ = std::fs::remove_file(&port_file);
977
978 // Reaching here means run_message_loop() returned, which ONLY happens on the
979 // intended LastWindowClosed quit — so a hard exit(0) is correct here, NOT a
980 // crash. It is also NECESSARY. After a CEF Views last-window close the
981 // browsers are HIDDEN/recycled (the close never fires on_before_close), so
982 // they're still alive at shutdown; CEF's teardown then access-violates on
983 // Windows (`cef::shutdown()` / `UnhookWinEvent`, exit 0xC0000005) and wedges
984 // in the tokio runtime drop on macOS. Either way the launcher (which
985 // classifies host exit) sees an ABNORMAL exit and RELAUNCHES the instance —
986 // the "reopens on its own" symptom (Discussion #1680). exit(0) gives the
987 // launcher a clean code-0 shutdown; it reaps the host's children via its Job
988 // Object (KILL_ON_JOB_CLOSE).
989 // Unhook the Win32 event hooks before exit (no-op off Windows; a cheap,
990 // safe UnhookWinEvent — NOT a crash site).
991 wrr::uninstall_hooks();
992 #[cfg(target_os = "macos")]
993 {
994 // macOS keeps the prior sequence (works): cef::shutdown() then a
995 // non-blocking runtime teardown (#1268).
996 shutdown();
997 runtime.shutdown_background();
998 }
999 #[cfg(not(target_os = "macos"))]
1000 {
1001 // Windows/Linux: SKIP cef::shutdown() — the Windows crash site on the
1002 // still-alive recycled browsers. Drop the tokio runtime (safe +
1003 // non-blocking here) before the hard exit.
1004 drop(runtime);
1005 }
1006 tracing::info!("AgentMux host shutdown complete (fast exit)");
1007
1008 // On Windows, hard-terminate so the C-runtime / CEF-DLL static teardown
1009 // (atexit handlers + DLL_PROCESS_DETACH) does NOT run: with the recycled
1010 // browsers still alive, that teardown raises a fail-fast (exit 0xC0000602)
1011 // even though we reached this line, which the launcher classifies as an
1012 // ABNORMAL exit and RELAUNCHES ("reopens on its own"). `std::process::exit`
1013 // still runs that cleanup, so use `TerminateProcess(self, 0)` for an
1014 // immediate, clean code-0 termination; the launcher then shuts down and
1015 // reaps the host's children via its Job Object. See Discussion #1680.
1016 #[cfg(target_os = "windows")]
1017 unsafe {
1018 use windows_sys::Win32::System::Threading::{GetCurrentProcess, TerminateProcess};
1019 TerminateProcess(GetCurrentProcess(), 0);
1020 }
1021 std::process::exit(0)
1022}
1023
1024/// Windows DLL wrapper sandbox entry point (Phase 3, issue #1374).
1025///
1026/// `bootstrap.exe` (renamed `agentmux-cef.exe`) calls this after:
1027/// 1. `cef_sandbox_info_create()` → `sandbox_info`
1028/// 2. `LoadLibraryW("agentmux-cef.dll")` → this DLL
1029/// 3. `GetProcAddress(hDll, "RunWinMain")` → this function
1030///
1031/// We forward `sandbox_info` into `run()` so CEF's `CefExecuteProcess` and
1032/// `CefInitialize` receive the pre-initialized sandbox context.
1033#[cfg(all(target_os = "windows", feature = "sandbox"))]
1034#[no_mangle]
1035pub unsafe extern "system" fn RunWinMain(
1036 _h_instance: windows_sys::Win32::Foundation::HINSTANCE,
1037 _lp_cmd_line: *mut u16,
1038 _n_cmd_show: i32,
1039 sandbox_info: *mut std::ffi::c_void,
1040 _version_info: *mut std::ffi::c_void,
1041) -> i32 {
1042 run(sandbox_info)
1043}
1044
1045/// macOS 26 Tahoe compatibility shim.
1046///
1047/// CEF 146 calls private `NSApplication` selectors (e.g. `isHandlingSendEvent`,
1048/// `isSendingEvent`) during `NSDraggingSession` setup that Apple removed in
1049/// macOS 26. Without a handler the ObjC runtime walks `___forwarding___`,
1050/// finds nothing, and `objc_exception_throw`s; AppKit's default uncaught
1051/// handler calls `_objc_terminate()` and the host dies with `EXC_BREAKPOINT`
1052/// before Rust panic machinery runs.
1053///
1054/// We hook `+[NSApplication resolveInstanceMethod:]` on the metaclass —
1055/// the earliest point in the ObjC dispatch chain — and install typed stubs
1056/// for any unknown selector *before* the forwarding machinery is entered.
1057/// Swizzling `doesNotRecognizeSelector:` would not work: that method is
1058/// invoked FROM inside `___forwarding___`, and returning normally without
1059/// throwing corrupts forwarding state and causes a secondary crash there.
1060///
1061/// Return-type matters: `isHandlingSendEvent` and `isSendingEvent` return
1062/// `BOOL` and callers act on the value. A `void` stub leaves `x0 = self`
1063/// (truthy) on ARM64, making CEF think the app is already inside a
1064/// `sendEvent:` call and skip normal event routing — which breaks window
1065/// drag silently. A maintained allowlist of `BOOL`-returning selectors
1066/// gets a `BOOL_no_stub` returning `0` (NO); everything else gets a void
1067/// stub, which is safe for the unbounded set of removed Apple-private APIs.
1068///
1069/// Safety: Called once, before `cef::initialize`. `NSApplication` is a
1070/// singleton; adding a `+resolveInstanceMethod:` implementation on its
1071/// metaclass at startup is documented Apple behavior. No allocations, no
1072/// crossings of language boundaries that hold Rust references.
1073///
1074/// Ported from PR #403 (a5af, 2026-04-15) with rationale comments expanded.
1075/// See `docs/specs/SPEC_MACOS_TEAROFF_STABILITY_2026_05_29.md` and
1076/// `docs/analysis/REPORT_MACOS_TEAROFF_DRAG_CRASH_2026_05_29.md`.
1077#[cfg(target_os = "macos")]
1078unsafe fn patch_nsapp_unrecognized_selector() {
1079 use std::ffi::{c_char, c_void};
1080
1081 type Id = *mut c_void;
1082 type Sel = *const c_void;
1083 type Class = *mut c_void;
1084
1085 extern "C" {
1086 fn objc_getClass(name: *const c_char) -> Class;
1087 fn object_getClass(obj: Id) -> Class; // on a Class obj → returns metaclass
1088 fn sel_registerName(name: *const c_char) -> Sel;
1089 fn sel_getName(sel: Sel) -> *const c_char;
1090 fn class_addMethod(cls: Class, sel: Sel, imp: usize, types: *const c_char) -> u8;
1091 }
1092
1093 // Void stub — safe for unknown selectors whose return value isn't read.
1094 unsafe extern "C" fn void_stub(_self: Id, _cmd: Sel) {}
1095
1096 // BOOL stub returning 0 (NO). On ARM64 the return value lives in `x0`;
1097 // a void stub leaves `x0 = self` (truthy), breaking CEF's sendEvent: guard.
1098 unsafe extern "C" fn bool_no_stub(_self: Id, _cmd: Sel) -> u8 { 0 }
1099
1100 // +resolveInstanceMethod: injected onto NSApplication's metaclass.
1101 // The ObjC runtime calls us the first time a selector is sent to an
1102 // NSApplication instance that has no implementation. We `class_addMethod`
1103 // a typed stub and return YES; the runtime retries the original message
1104 // against the freshly added method.
1105 unsafe extern "C" fn resolve_instance_method_impl(
1106 cls: Class,
1107 _cmd: Sel,
1108 sel: Sel,
1109 ) -> u8 {
1110 let name = {
1111 let ptr = sel_getName(sel);
1112 if ptr.is_null() {
1113 "<unknown>".to_owned()
1114 } else {
1115 std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned()
1116 }
1117 };
1118
1119 // BOOL-returning getters whose value callers act on. The truthy
1120 // garbage a void stub would leave in x0 breaks event routing.
1121 const BOOL_NO_SELECTORS: &[&str] = &[
1122 "isHandlingSendEvent",
1123 "isSendingEvent",
1124 ];
1125
1126 if BOOL_NO_SELECTORS.contains(&name.as_str()) {
1127 tracing::warn!(selector = %name, "macOS 26 compat: adding BOOL(NO) stub");
1128 class_addMethod(cls, sel, bool_no_stub as usize, b"c@:\0".as_ptr() as _);
1129 } else {
1130 tracing::warn!(selector = %name, "macOS 26 compat: adding void stub");
1131 class_addMethod(cls, sel, void_stub as usize, b"v@:\0".as_ptr() as _);
1132 }
1133 1 // YES — resolved; runtime retries the original send
1134 }
1135
1136 let cls = objc_getClass(b"NSApplication\0".as_ptr() as _);
1137 if cls.is_null() {
1138 tracing::warn!("macOS 26 compat: NSApplication class not found");
1139 return;
1140 }
1141
1142 // +resolveInstanceMethod: is a class method; it lives on the metaclass.
1143 let metacls = object_getClass(cls as Id);
1144 if metacls.is_null() {
1145 tracing::warn!("macOS 26 compat: NSApplication metaclass not found");
1146 return;
1147 }
1148
1149 let sel = sel_registerName(b"resolveInstanceMethod:\0".as_ptr() as _);
1150 // "c@::" = BOOL return, id (Class), SEL (cmd), SEL (queried selector)
1151 let added = class_addMethod(
1152 metacls,
1153 sel,
1154 resolve_instance_method_impl as usize,
1155 b"c@::\0".as_ptr() as _,
1156 );
1157 if added != 0 {
1158 tracing::info!("macOS 26 compat: injected resolveInstanceMethod: into NSApplication metaclass");
1159 } else {
1160 tracing::warn!("macOS 26 compat: class_addMethod failed (method already exists?)");
1161 }
1162}
1163
1164/// Suppress AppKit's native drag slide-back animation app-wide.
1165///
1166/// When an `NSDraggingSession` ends without a successful drop (the drag
1167/// operation resolves to `NSDragOperationNone`), AppKit animates the drag
1168/// image sliding back to where the drag began. For a pane/tab tear-off the
1169/// pointer is released outside any DOM drop target — the floating window is
1170/// created on mouseup — so blink reports `NSDragOperationNone` and the user
1171/// sees the drag image fly back into the source window before the new
1172/// window appears. That "rejection" animation is exactly what tear-off
1173/// wants gone; the frontend `preventUnhandled` guard (PR #1186) only
1174/// suppresses the WebKit-level snapback for *in-document* drops and can't
1175/// reach this AppKit-level animation.
1176///
1177/// `NSDraggingSession` exposes `animatesToStartingPositionsOnCancelOrFail`
1178/// (default `YES`) to control exactly this. CEF/Chromium starts every drag
1179/// via `-[NSView beginDraggingSessionWithItems:event:source:]` and never
1180/// flips the flag, so we swizzle that method: call the original, then set
1181/// the flag to `NO` on the returned session. Done at the `NSView` level so
1182/// it covers whichever Chromium content view initiates the drag. The flag
1183/// only affects the cancel/fail slide-back — successful in-window drops
1184/// (e.g. tab reorder) are unaffected — so disabling it globally is safe;
1185/// there is no place in the app where a drop-rejected slide-back is wanted.
1186///
1187/// Safety: called once at startup, before `cef::initialize`, on the main
1188/// thread. Mirrors the raw-libobjc FFI of `patch_nsapp_unrecognized_selector`.
1189#[cfg(target_os = "macos")]
1190unsafe fn disable_macos_drag_slideback() {
1191 use std::ffi::{c_char, c_void};
1192
1193 type Id = *mut c_void;
1194 type Sel = *const c_void;
1195 type Class = *mut c_void;
1196 type Method = *mut c_void;
1197 type Imp = *const c_void;
1198
1199 extern "C" {
1200 fn objc_getClass(name: *const c_char) -> Class;
1201 fn sel_registerName(name: *const c_char) -> Sel;
1202 fn class_getInstanceMethod(cls: Class, sel: Sel) -> Method;
1203 fn method_getImplementation(m: Method) -> Imp;
1204 fn method_setImplementation(m: Method, imp: Imp) -> Imp;
1205 fn objc_msgSend();
1206 }
1207
1208 // IMP of the original beginDraggingSessionWithItems:event:source:, saved
1209 // so our replacement can chain to it. Single-threaded startup write +
1210 // main-thread-only drag reads, so a plain static is sufficient.
1211 static mut ORIGINAL_BEGIN_DRAG: Imp = std::ptr::null();
1212
1213 // Replacement IMP: call the original to create the session, then clear
1214 // the slide-back flag on it before returning.
1215 unsafe extern "C" fn begin_drag_no_slideback(
1216 this: Id,
1217 cmd: Sel,
1218 items: Id,
1219 event: Id,
1220 source: Id,
1221 ) -> Id {
1222 let orig: extern "C" fn(Id, Sel, Id, Id, Id) -> Id =
1223 std::mem::transmute(ORIGINAL_BEGIN_DRAG);
1224 let session = orig(this, cmd, items, event, source);
1225 if !session.is_null() {
1226 // [session setAnimatesToStartingPositionsOnCancelOrFail:NO]
1227 let sel = sel_registerName(
1228 b"setAnimatesToStartingPositionsOnCancelOrFail:\0".as_ptr() as _,
1229 );
1230 let set_flag: extern "C" fn(Id, Sel, u8) =
1231 std::mem::transmute(objc_msgSend as *const c_void);
1232 set_flag(session, sel, 0); // NO
1233 }
1234 session
1235 }
1236
1237 let cls = objc_getClass(b"NSView\0".as_ptr() as _);
1238 if cls.is_null() {
1239 tracing::warn!("drag slide-back: NSView class not found");
1240 return;
1241 }
1242 let sel = sel_registerName(
1243 b"beginDraggingSessionWithItems:event:source:\0".as_ptr() as _,
1244 );
1245 let method = class_getInstanceMethod(cls, sel);
1246 if method.is_null() {
1247 tracing::warn!("drag slide-back: beginDraggingSessionWithItems:event:source: not found");
1248 return;
1249 }
1250 ORIGINAL_BEGIN_DRAG = method_getImplementation(method);
1251 method_setImplementation(method, begin_drag_no_slideback as Imp);
1252 tracing::info!(
1253 "macOS drag polish: swizzled NSView beginDraggingSession to disable cancel/fail slide-back"
1254 );
1255}
1256
1257/// Set the macOS app display name (Dock tile + app-menu title).
1258///
1259/// A bundle-less binary (`task dev` direct-invoke, the launcher's flat
1260/// `dist/cef-dev/` layout) has no `Info.plist`, so AppKit derives the app name
1261/// from the process name — `agentmux-cef` — which is what shows in the Dock and
1262/// the menu bar's app menu. Override it with a friendly name: **dev** builds get
1263/// `AgentMux DEV` (so they're visibly distinct from a packaged `AgentMux` and
1264/// from each other when several instances run), everything else gets `AgentMux`.
1265/// A packaged `.app` carries `CFBundleName` in its `Info.plist`; this still runs
1266/// and simply matches it.
1267///
1268/// Uses `-[NSProcessInfo setProcessName:]`, which AppKit reads for the Dock
1269/// label and the app-menu title. Must run AFTER `cef::initialize` — Chromium
1270/// overwrites the process name during init, so a pre-init set is clobbered;
1271/// setting it here (right before our menu bar is built) is what sticks. Raw
1272/// libobjc FFI, mirroring the other macOS shims.
1273#[cfg(target_os = "macos")]
1274unsafe fn set_macos_app_display_name() {
1275 use std::ffi::{c_char, c_void};
1276
1277 type Id = *mut c_void;
1278 type Sel = *const c_void;
1279 type Class = *mut c_void;
1280
1281 extern "C" {
1282 fn objc_getClass(name: *const c_char) -> Class;
1283 fn sel_registerName(name: *const c_char) -> Sel;
1284 fn objc_msgSend();
1285 }
1286
1287 // Resolve dev vs. not by the exe PATH (`is_dev_self`), matching
1288 // `commands::platform::get_is_dev` and the menu name in `macos_menu.rs`.
1289 // NOT `AGENTMUX_RUNTIME_MODE`: a parent dev AgentMux leaks that env into
1290 // descendants, which would otherwise set the Dock / app-menu process name
1291 // to "AgentMux DEV" on a packaged build launched from inside a dev
1292 // instance. Build identity is a property of the binary on disk.
1293 let name = if agentmux_common::is_dev_self() { "AgentMux DEV" } else { "AgentMux" };
1294
1295 // NSString *ns = [NSString stringWithUTF8String:name]
1296 let cls_str = objc_getClass(b"NSString\0".as_ptr() as _);
1297 if cls_str.is_null() {
1298 return;
1299 }
1300 let sel_with = sel_registerName(b"stringWithUTF8String:\0".as_ptr() as _);
1301 let make: extern "C" fn(Class, Sel, *const c_char) -> Id =
1302 std::mem::transmute(objc_msgSend as *const c_void);
1303 let cname = match std::ffi::CString::new(name) {
1304 Ok(c) => c,
1305 Err(_) => return,
1306 };
1307 let ns_name = make(cls_str, sel_with, cname.as_ptr());
1308 if ns_name.is_null() {
1309 return;
1310 }
1311
1312 // pi = [NSProcessInfo processInfo]
1313 let cls_pi = objc_getClass(b"NSProcessInfo\0".as_ptr() as _);
1314 if cls_pi.is_null() {
1315 return;
1316 }
1317 let sel_pi = sel_registerName(b"processInfo\0".as_ptr() as _);
1318 let get_pi: extern "C" fn(Class, Sel) -> Id =
1319 std::mem::transmute(objc_msgSend as *const c_void);
1320 let pi = get_pi(cls_pi, sel_pi);
1321 if pi.is_null() {
1322 return;
1323 }
1324
1325 // [pi setProcessName:ns_name]
1326 let sel_set = sel_registerName(b"setProcessName:\0".as_ptr() as _);
1327 let set_name: extern "C" fn(Id, Sel, Id) =
1328 std::mem::transmute(objc_msgSend as *const c_void);
1329 set_name(pi, sel_set, ns_name);
1330
1331 // The app-menu title + Dock label for an unbundled binary come from the
1332 // main bundle's CFBundleName/CFBundleDisplayName, NOT the process name
1333 // (setProcessName above doesn't move them). Set them on the main bundle's
1334 // info dictionary, which is backed by a mutable dictionary. Guard on
1335 // isKindOfClass:NSMutableDictionary so an unexpected immutable dict is a
1336 // skip rather than a throw (an uncaught ObjC exception would terminate the
1337 // host on macOS 26).
1338 let cls_bundle = objc_getClass(b"NSBundle\0".as_ptr() as _);
1339 if !cls_bundle.is_null() {
1340 let sel_main = sel_registerName(b"mainBundle\0".as_ptr() as _);
1341 let get_main: extern "C" fn(Class, Sel) -> Id =
1342 std::mem::transmute(objc_msgSend as *const c_void);
1343 let bundle = get_main(cls_bundle, sel_main);
1344 if !bundle.is_null() {
1345 let sel_info = sel_registerName(b"infoDictionary\0".as_ptr() as _);
1346 let get_info: extern "C" fn(Id, Sel) -> Id =
1347 std::mem::transmute(objc_msgSend as *const c_void);
1348 let info = get_info(bundle, sel_info);
1349 let cls_mut = objc_getClass(b"NSMutableDictionary\0".as_ptr() as _);
1350 let sel_kind = sel_registerName(b"isKindOfClass:\0".as_ptr() as _);
1351 let is_kind: extern "C" fn(Id, Sel, Class) -> u8 =
1352 std::mem::transmute(objc_msgSend as *const c_void);
1353 if !info.is_null() && !cls_mut.is_null() && is_kind(info, sel_kind, cls_mut) != 0 {
1354 let sel_set_obj = sel_registerName(b"setObject:forKey:\0".as_ptr() as _);
1355 let set_obj: extern "C" fn(Id, Sel, Id, Id) =
1356 std::mem::transmute(objc_msgSend as *const c_void);
1357 let k_name = make(cls_str, sel_with, b"CFBundleName\0".as_ptr() as _);
1358 let k_disp = make(cls_str, sel_with, b"CFBundleDisplayName\0".as_ptr() as _);
1359 set_obj(info, sel_set_obj, ns_name, k_name);
1360 set_obj(info, sel_set_obj, ns_name, k_disp);
1361 tracing::info!("macOS: set CFBundleName/CFBundleDisplayName on main bundle");
1362 } else {
1363 tracing::warn!("macOS: main bundle info dict not mutable; app name unchanged");
1364 }
1365 }
1366 }
1367
1368 tracing::info!(app_name = name, "macOS: set app display name (Dock + app menu)");
1369}
1370
1371/// macOS accessibility activation governor — Layer 1 of
1372/// `docs/specs/SPEC_MACOS_ACCESSIBILITY_ROBUSTNESS_2026-06-03.md`.
1373///
1374/// Chromium enables its web-content accessibility tree the moment an AX client
1375/// sets `AXEnhancedUserInterface` on the application. That tree's macOS
1376/// implementation faults under external iteration on macOS-26 / CEF M114+
1377/// (CEF #3512: `AXPlatformNodeCocoa::AXChildren()` SEGV; here it surfaced as an
1378/// `EXC_BREAKPOINT` through the legacy `NSAccessibility…` accessor when a user
1379/// clicked the title-bar menu). The trigger attribute is **overloaded**:
1380/// VoiceOver sets it, but so do ordinary window managers / KVMs — Magnet,
1381/// Synergy — see Firefox bug 1664992. So a window manager merely doing its job
1382/// forces AgentMux into the crash-prone full-AX mode.
1383///
1384/// This swizzles the application's legacy `accessibilitySetValue:forAttribute:`
1385/// and applies a policy:
1386/// * `AXEnhancedUserInterface` (the window-manager/KVM path) does **not**
1387/// auto-enable web-content AX — unless `AGENTMUX_A11Y_HONOR_ENHANCED=1`.
1388/// * `AXManualAccessibility` (explicit assistive-technology / app intent —
1389/// the path Electron documents for enabling AX) **is** honored.
1390/// * every set is logged so the real activation path is observable in the
1391/// field (this is also how we confirm the fix against Magnet/Synergy).
1392///
1393/// Window-level AX (windows, buttons, title) is unaffected — only the descent
1394/// into the crash-prone web-content tree is gated. Full screen-reader support
1395/// returns unconditionally once the AX path itself is made non-fatal (Phase 2 /
1396/// Layer 2 of the spec). Not a blanket `--disable-renderer-accessibility`: that
1397/// would make AgentMux permanently inaccessible; this keeps the explicit
1398/// (`AXManualAccessibility`) enable path working.
1399///
1400/// Must run AFTER `cef::initialize` — the CEF `NSApplication` subclass that
1401/// implements the legacy AX setter only exists then. FFI mirrors
1402/// `disable_macos_drag_slideback`. Idempotent enough for once-at-startup.
1403#[cfg(target_os = "macos")]
1404unsafe fn install_macos_accessibility_governor() {
1405 use std::ffi::{c_char, c_void};
1406
1407 type Id = *mut c_void;
1408 type Sel = *const c_void;
1409 type Class = *mut c_void;
1410 type Method = *mut c_void;
1411 type Imp = *const c_void;
1412
1413 extern "C" {
1414 fn objc_getClass(name: *const c_char) -> Class;
1415 fn object_getClass(obj: Id) -> Class;
1416 fn class_getName(cls: Class) -> *const c_char;
1417 fn sel_registerName(name: *const c_char) -> Sel;
1418 fn class_getInstanceMethod(cls: Class, sel: Sel) -> Method;
1419 fn method_getImplementation(m: Method) -> Imp;
1420 fn method_setImplementation(m: Method, imp: Imp) -> Imp;
1421 fn objc_msgSend();
1422 }
1423
1424 // Original Chromium IMP, saved so the governed replacement can chain to it.
1425 // Single-threaded startup write; main-thread-only AX reads afterward.
1426 static mut ORIGINAL_SET_AX: Imp = std::ptr::null();
1427 // Read the override once at install (env reads in the hot path are wasteful
1428 // and AX sets are rare, but a static keeps the IMP allocation-free).
1429 static mut HONOR_ENHANCED: bool = false;
1430
1431 // [attr isEqualToString:@literal] without bringing in a string crate.
1432 unsafe fn attr_is(attr: Id, literal: &[u8]) -> bool {
1433 if attr.is_null() {
1434 return false;
1435 }
1436 let objc_get_class: extern "C" fn(*const c_char) -> Class =
1437 std::mem::transmute(objc_getClass as *const c_void);
1438 let cls = objc_get_class(b"NSString\0".as_ptr() as _);
1439 if cls.is_null() {
1440 return false;
1441 }
1442 let sel_with = sel_registerName(b"stringWithUTF8String:\0".as_ptr() as _);
1443 let make: extern "C" fn(Class, Sel, *const c_char) -> Id =
1444 std::mem::transmute(objc_msgSend as *const c_void);
1445 let lit = make(cls, sel_with, literal.as_ptr() as *const c_char);
1446 if lit.is_null() {
1447 return false;
1448 }
1449 let sel_eq = sel_registerName(b"isEqualToString:\0".as_ptr() as _);
1450 let eq: extern "C" fn(Id, Sel, Id) -> u8 =
1451 std::mem::transmute(objc_msgSend as *const c_void);
1452 eq(attr, sel_eq, lit) != 0
1453 }
1454
1455 // Replacement for -[<app> accessibilitySetValue:(id)value forAttribute:(NSString*)attr].
1456 unsafe extern "C" fn governed_set_ax(this: Id, cmd: Sel, value: Id, attribute: Id) {
1457 if attr_is(attribute, b"AXEnhancedUserInterface\0") {
1458 if !HONOR_ENHANCED {
1459 tracing::warn!(
1460 "a11y governor: blocked AXEnhancedUserInterface activation \
1461 (window-manager/KVM path — CEF #3512). \
1462 Set AGENTMUX_A11Y_HONOR_ENHANCED=1 to allow."
1463 );
1464 return; // swallow → web-content AX stays off
1465 }
1466 tracing::warn!("a11y governor: honoring AXEnhancedUserInterface (override enabled)");
1467 } else if attr_is(attribute, b"AXManualAccessibility\0") {
1468 tracing::info!("a11y governor: honoring AXManualAccessibility (explicit enable)");
1469 } else {
1470 tracing::debug!("a11y governor: passthrough accessibilitySetValue:forAttribute:");
1471 }
1472 let orig: extern "C" fn(Id, Sel, Id, Id) = std::mem::transmute(ORIGINAL_SET_AX);
1473 orig(this, cmd, value, attribute);
1474 }
1475
1476 // Honor only an explicit truthy value — keying on presence would make
1477 // `AGENTMUX_A11Y_HONOR_ENHANCED=0` *enable* the override (reagent P2).
1478 HONOR_ENHANCED = std::env::var("AGENTMUX_A11Y_HONOR_ENHANCED")
1479 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1480 .unwrap_or(false);
1481
1482 // app = [NSApplication sharedApplication]
1483 let cls_nsapp = objc_getClass(b"NSApplication\0".as_ptr() as _);
1484 if cls_nsapp.is_null() {
1485 tracing::warn!("a11y governor: NSApplication class not found; skipping");
1486 return;
1487 }
1488 let sel_shared = sel_registerName(b"sharedApplication\0".as_ptr() as _);
1489 let shared: extern "C" fn(Class, Sel) -> Id =
1490 std::mem::transmute(objc_msgSend as *const c_void);
1491 let app = shared(cls_nsapp, sel_shared);
1492 if app.is_null() {
1493 tracing::warn!("a11y governor: sharedApplication nil; skipping");
1494 return;
1495 }
1496
1497 let app_cls = object_getClass(app);
1498 let cls_name = {
1499 let p = class_getName(app_cls);
1500 if p.is_null() {
1501 "<unknown>".to_owned()
1502 } else {
1503 std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
1504 }
1505 };
1506
1507 // --- Layer 2a guard (LOAD-BEARING): -[NSApplication accessibilityParent] → nil ---
1508 //
1509 // Installed FIRST and INDEPENDENTLY of the Layer 1 setter swizzle below, so a
1510 // macOS/CEF build that lacks the legacy setter still gets this fix (reagent P1
1511 // — the setter was previously a gate that skipped this guard on early return).
1512 //
1513 // The observed crash (EXC_BREAKPOINT, both reports) is an *incoming* AX READ —
1514 // an external client (Magnet/Synergy) calls CopyAttributeValue on the app,
1515 // which routes through:
1516 // -[NSApplication accessibilityParent]
1517 // → NSAccessibilityGetObjectForAttributeUsingLegacyAPI
1518 // → NSAccessibilitySetUnsupportedAttributeError
1519 // → +[NSString stringWithFormat:] → CF string trap (with a CEF AX object
1520 // as the %@ arg — CEF #3512).
1521 // NSApplication is the AX root; its parent is legitimately nil. Returning nil
1522 // DIRECTLY short-circuits before the crashy legacy bridge runs. Safe and
1523 // semantically correct, and it does not disable accessibility — windows/title
1524 // still answer.
1525 unsafe extern "C" fn accessibility_parent_nil(_this: Id, _cmd: Sel) -> Id {
1526 std::ptr::null_mut()
1527 }
1528 let sel_parent = sel_registerName(b"accessibilityParent\0".as_ptr() as _);
1529 let m_parent = class_getInstanceMethod(app_cls, sel_parent);
1530 if !m_parent.is_null() {
1531 method_setImplementation(m_parent, accessibility_parent_nil as Imp);
1532 tracing::info!(app_class = %cls_name, "a11y governor: guarded -[NSApplication accessibilityParent] → nil (SPEC L2a)");
1533 } else {
1534 tracing::warn!(app_class = %cls_name, "a11y governor: accessibilityParent not found on app class");
1535 }
1536
1537 // --- Layer 1 (defense in depth): govern AXEnhancedUserInterface activation ---
1538 // Independent of L2a above; if the legacy setter is absent we just log and the
1539 // load-bearing parent guard still stands.
1540 let sel_set = sel_registerName(b"accessibilitySetValue:forAttribute:\0".as_ptr() as _);
1541 let method = class_getInstanceMethod(app_cls, sel_set);
1542 if !method.is_null() {
1543 ORIGINAL_SET_AX = method_getImplementation(method);
1544 method_setImplementation(method, governed_set_ax as Imp);
1545 tracing::info!(
1546 honor_enhanced = HONOR_ENHANCED,
1547 "a11y governor: swizzled accessibilitySetValue:forAttribute: (SPEC L1)"
1548 );
1549 } else {
1550 tracing::warn!(
1551 "a11y governor: accessibilitySetValue:forAttribute: not found on app class — \
1552 activation governor inactive (parent guard still installed)"
1553 );
1554 }
1555}
1556
1557/// Promote the process to a regular, Dock-visible macOS app.
1558///
1559/// A bare Mach-O launched outside an `.app` bundle — both `task dev`
1560/// direct-invoke and the launcher's flat `dist/cef-dev/` layout — has no
1561/// `Info.plist`, so LaunchServices leaves it as a background/accessory
1562/// process: it can open windows but gets **no Dock tile and no menu bar**, so
1563/// the AgentMux instance is invisible in the taskbar (`lsappinfo` reports it
1564/// `type="BackgroundOnly"`). `-[NSApplication setActivationPolicy:]` with
1565/// `NSApplicationActivationPolicyRegular` (raw value `0`) flips it to a normal
1566/// foreground app so it shows in the Dock; this must run before
1567/// `set_macos_dock_icon` (the icon needs a tile to land on). Harmless in a
1568/// future packaged `.app` (already regular there). Idempotent.
1569///
1570/// Must run on the main thread after `cef::initialize` (NSApplication exists
1571/// by then). FFI mirrors `set_macos_dock_icon` — raw libobjc, no extra crate.
1572#[cfg(target_os = "macos")]
1573unsafe fn set_macos_activation_policy_regular() {
1574 use std::ffi::{c_char, c_void};
1575
1576 type Id = *mut c_void;
1577 type Sel = *const c_void;
1578 type Class = *mut c_void;
1579
1580 // NSApplicationActivationPolicyRegular == 0 (Accessory == 1, Prohibited == 2).
1581 const NS_ACTIVATION_POLICY_REGULAR: isize = 0;
1582
1583 extern "C" {
1584 fn objc_getClass(name: *const c_char) -> Class;
1585 fn sel_registerName(name: *const c_char) -> Sel;
1586 fn objc_msgSend();
1587 }
1588
1589 let cls_nsapp = objc_getClass(b"NSApplication\0".as_ptr() as _);
1590 if cls_nsapp.is_null() {
1591 tracing::warn!("activation-policy: NSApplication class not found; skipping");
1592 return;
1593 }
1594
1595 // NSApplication *app = [NSApplication sharedApplication]
1596 let sel_shared = sel_registerName(b"sharedApplication\0".as_ptr() as _);
1597 let msg_shared: extern "C" fn(Class, Sel) -> Id =
1598 std::mem::transmute(objc_msgSend as *const ());
1599 let app = msg_shared(cls_nsapp, sel_shared);
1600 if app.is_null() {
1601 tracing::warn!("activation-policy: NSApplication.sharedApplication unavailable");
1602 return;
1603 }
1604
1605 // BOOL ok = [app setActivationPolicy:NSApplicationActivationPolicyRegular]
1606 let sel_set = sel_registerName(b"setActivationPolicy:\0".as_ptr() as _);
1607 let msg_set: extern "C" fn(Id, Sel, isize) -> u8 =
1608 std::mem::transmute(objc_msgSend as *const ());
1609 let ok = msg_set(app, sel_set, NS_ACTIVATION_POLICY_REGULAR);
1610 tracing::info!(ok = ok != 0, "activation-policy: set NSApplication to Regular (Dock-visible)");
1611}
1612
1613/// Set the macOS Dock icon for the running process.
1614///
1615/// `task dev` launches the bare `agentmux-cef` Mach-O directly (not inside an
1616/// `.app` bundle — see `Taskfile.yml::dev:serve`), so macOS has no
1617/// `CFBundleIconFile` to read and shows the generic executable tile in the
1618/// Dock. Rather than restructure the dev launch around a bundle, we set the
1619/// icon at runtime via `-[NSApplication setApplicationIconImage:]`, which
1620/// works whether or not we're in a bundle and also overrides a bundle icon
1621/// in any future packaged build — one code path for all launch modes.
1622///
1623/// The PNG is embedded at compile time (`include_bytes!`) so there's no
1624/// dependency on the `dist/` layout or a resource-path lookup at runtime.
1625/// It's the SAME normal AgentMux logo the Linux taskbar uses
1626/// (`assets/linux/icons/hicolor/512x512/apps/agentmux.png`, wired up in
1627/// `scripts/install-linux-desktop.sh`), keeping the Dock/taskbar icon
1628/// identical across platforms.
1629///
1630/// Must run on the main thread after `cef::initialize` (NSApplication exists
1631/// by then). FFI mirrors `patch_nsapp_unrecognized_selector` — raw libobjc,
1632/// no `objc2`/`cocoa` crate dependency. The created NSImage is intentionally
1633/// leaked (one per process lifetime): `setApplicationIconImage:` retains it
1634/// and the icon lives as long as the app does.
1635#[cfg(target_os = "macos")]
1636unsafe fn set_macos_dock_icon() {
1637 use std::ffi::{c_char, c_void};
1638
1639 type Id = *mut c_void;
1640 type Sel = *const c_void;
1641 type Class = *mut c_void;
1642
1643 // The normal AgentMux logo (panel layout, not the brain-alternate),
1644 // matching the Linux taskbar source.
1645 const ICON_PNG: &[u8] =
1646 include_bytes!("../../assets/linux/icons/hicolor/512x512/apps/agentmux.png");
1647
1648 extern "C" {
1649 fn objc_getClass(name: *const c_char) -> Class;
1650 fn sel_registerName(name: *const c_char) -> Sel;
1651 // objc_msgSend is declared bare and transmuted to the exact prototype
1652 // at each call site — the ARM64 calling convention requires the real
1653 // signature, not a variadic stand-in.
1654 fn objc_msgSend();
1655 }
1656
1657 let cls_nsdata = objc_getClass(b"NSData\0".as_ptr() as _);
1658 let cls_nsimage = objc_getClass(b"NSImage\0".as_ptr() as _);
1659 let cls_nsapp = objc_getClass(b"NSApplication\0".as_ptr() as _);
1660 if cls_nsdata.is_null() || cls_nsimage.is_null() || cls_nsapp.is_null() {
1661 tracing::warn!("dock-icon: an AppKit class was not found; skipping");
1662 return;
1663 }
1664
1665 // NSData *data = [NSData dataWithBytes:ICON_PNG.ptr length:ICON_PNG.len]
1666 let sel_data = sel_registerName(b"dataWithBytes:length:\0".as_ptr() as _);
1667 let msg_data: extern "C" fn(Class, Sel, *const c_void, usize) -> Id =
1668 std::mem::transmute(objc_msgSend as *const ());
1669 let data = msg_data(cls_nsdata, sel_data, ICON_PNG.as_ptr() as *const c_void, ICON_PNG.len());
1670 if data.is_null() {
1671 tracing::warn!("dock-icon: NSData creation failed");
1672 return;
1673 }
1674
1675 // NSImage *img = [[NSImage alloc] initWithData:data]
1676 let sel_alloc = sel_registerName(b"alloc\0".as_ptr() as _);
1677 let msg_alloc: extern "C" fn(Class, Sel) -> Id =
1678 std::mem::transmute(objc_msgSend as *const ());
1679 let img_alloc = msg_alloc(cls_nsimage, sel_alloc);
1680 let sel_init = sel_registerName(b"initWithData:\0".as_ptr() as _);
1681 let msg_init: extern "C" fn(Id, Sel, Id) -> Id =
1682 std::mem::transmute(objc_msgSend as *const ());
1683 let image = msg_init(img_alloc, sel_init, data);
1684 if image.is_null() {
1685 tracing::warn!("dock-icon: NSImage creation failed (corrupt PNG?)");
1686 return;
1687 }
1688
1689 // NSApplication *app = [NSApplication sharedApplication]
1690 let sel_shared = sel_registerName(b"sharedApplication\0".as_ptr() as _);
1691 let msg_shared: extern "C" fn(Class, Sel) -> Id =
1692 std::mem::transmute(objc_msgSend as *const ());
1693 let app = msg_shared(cls_nsapp, sel_shared);
1694 if app.is_null() {
1695 tracing::warn!("dock-icon: NSApplication.sharedApplication unavailable");
1696 return;
1697 }
1698
1699 // [app setApplicationIconImage:img]
1700 let sel_set = sel_registerName(b"setApplicationIconImage:\0".as_ptr() as _);
1701 let msg_set: extern "C" fn(Id, Sel, Id) =
1702 std::mem::transmute(objc_msgSend as *const ());
1703 msg_set(app, sel_set, image);
1704
1705 tracing::info!("dock-icon: set NSApplication icon to embedded AgentMux logo");
1706}
1707
1708/// Initialize tracing with dual output: rolling daily log file + human-readable stderr.
1709/// `log_dir` is resolved by the caller: `<portable-root>/data/logs/` in portable mode,
1710/// `~/.agentmux/logs/` in installed mode.
1711/// Returns a guard that must be held for the lifetime of the process to ensure log flushing.
1712fn init_logging(log_dir: &std::path::Path) -> tracing_appender::non_blocking::WorkerGuard {
1713 use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter};
1714
1715 let version = env!("CARGO_PKG_VERSION");
1716 let _ = std::fs::create_dir_all(log_dir);
1717
1718 // Delete log files older than 7 days to prevent unbounded growth.
1719 cleanup_old_logs(&log_dir, 7);
1720
1721 let log_prefix = format!("agentmux-host-v{}.log", version);
1722 let file_appender = tracing_appender::rolling::daily(&log_dir, &log_prefix);
1723 let (non_blocking_file, guard) = tracing_appender::non_blocking(file_appender);
1724
1725 // Write pointer to current log file for zero-lookup agent discovery.
1726 // Version-qualified name so multi-instance doesn't clobber pointers.
1727 // Uses UTC to match tracing_appender::rolling::daily's date suffix.
1728 let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
1729 let current_filename = format!("{}.{}", log_prefix, today);
1730 let absolute_path = log_dir.join(¤t_filename);
1731 let pointer_name = format!("current-host-v{}.path", version);
1732
1733 // Pointer #1: local — inside the instance's log dir. The basename
1734 // is enough here since the reader is colocated.
1735 let _ = std::fs::write(log_dir.join(&pointer_name), ¤t_filename);
1736
1737 // Pointer #2: global — at `<root>/logs/<pointer_name>`. Writes the
1738 // ABSOLUTE PATH so legacy tooling (`muxlog host`) that lives outside
1739 // the instance dir can `cat $pointer | xargs tail -f` and reach the
1740 // real file. Skipped silently if the global dir can't be derived
1741 // (e.g. AGENTMUX_HOME_OVERRIDE unset in some test setups).
1742 if let Some(global_logs_dir) = log_dir.parent().and_then(|p| p.parent()).and_then(|p| p.parent()).map(|p| p.join("logs")) {
1743 let _ = std::fs::create_dir_all(&global_logs_dir);
1744 let _ = std::fs::write(
1745 global_logs_dir.join(&pointer_name),
1746 absolute_path.to_string_lossy().as_bytes(),
1747 );
1748 }
1749
1750 // Synchronous init sentinel: append a single line directly to the
1751 // expected log path BEFORE the tracing subscriber is wired up. Without
1752 // this, a hang between subscriber-setup and the non-blocking writer's
1753 // first flush leaves the pointer file pointing at a never-created log
1754 // file (observed 2026-05-02 freeze investigation). The sentinel
1755 // guarantees the file exists once init_logging has run past
1756 // pointer-write — if the file is missing afterwards, we know
1757 // init_logging itself didn't get past this point.
1758 let sentinel_path = log_dir.join(¤t_filename);
1759 let sentinel_line = format!(
1760 "{} INIT-SENTINEL agentmux-host v={} pid={} os={} arch={}\n",
1761 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
1762 version,
1763 std::process::id(),
1764 std::env::consts::OS,
1765 std::env::consts::ARCH,
1766 );
1767 if let Ok(mut f) = std::fs::OpenOptions::new()
1768 .create(true)
1769 .append(true)
1770 .open(&sentinel_path)
1771 {
1772 use std::io::Write;
1773 let _ = f.write_all(sentinel_line.as_bytes());
1774 let _ = f.flush();
1775 }
1776
1777 let subscriber = tracing_subscriber::registry()
1778 .with(
1779 EnvFilter::try_from_default_env()
1780 .unwrap_or_else(|_| EnvFilter::new("info")),
1781 )
1782 .with(
1783 fmt::layer()
1784 .json()
1785 .with_writer(non_blocking_file)
1786 .with_target(true)
1787 .with_thread_ids(true),
1788 )
1789 .with(
1790 fmt::layer()
1791 .with_writer(std::io::stderr)
1792 .with_ansi(true),
1793 );
1794
1795 tracing::subscriber::set_global_default(subscriber).ok();
1796
1797 tracing::info!(
1798 version,
1799 os = std::env::consts::OS,
1800 arch = std::env::consts::ARCH,
1801 log_dir = %log_dir.display(),
1802 "AgentMux host starting"
1803 );
1804
1805 guard
1806}
1807
1808fn cleanup_old_logs(log_dir: &std::path::Path, days: u64) {
1809 let cutoff = std::time::SystemTime::now()
1810 - std::time::Duration::from_secs(days * 86400);
1811 let Ok(entries) = std::fs::read_dir(log_dir) else { return };
1812 for entry in entries.flatten() {
1813 let path = entry.path();
1814 if !path.to_string_lossy().contains(".log.") {
1815 continue;
1816 }
1817 if let Ok(meta) = entry.metadata() {
1818 if let Ok(modified) = meta.modified() {
1819 if modified < cutoff {
1820 let _ = std::fs::remove_file(&path);
1821 }
1822 }
1823 }
1824 }
1825}