Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 104 additions & 3 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,74 @@ tasks:
- echo "MSIX packaging not yet implemented for CEF host. Old Tauri script was removed."

package:macos:
desc: "[TODO] Package the application for macOS (CEF). Not yet implemented."
desc: Package the application for macOS (.app + DMG). Outputs to ~/Desktop.
platforms: [darwin]
deps: [build:frontend, build:backend, cef:build]
cmds:
- echo "macOS CEF packaging not yet implemented. Use cef:package:portable on Windows."
- task: cef:bundle
- |
VERSION=$(node -p "require('./package.json').version")
ARCH="arm64"
APP="dist/AgentMux.app"
MACOS="$APP/Contents/MacOS"
FRAMEWORKS="$APP/Contents/Frameworks"

# Scaffold .app structure
rm -rf "$APP"
mkdir -p "$MACOS" "$FRAMEWORKS" "$APP/Contents/Resources"

# CEF host binary (CFBundleExecutable)
cp dist/cef/agentmux-cef "$MACOS/agentmux-cef"
chmod +x "$MACOS/agentmux-cef"

# CEF framework — library_loader resolves {exe_dir}/../Frameworks/
# which maps to Contents/MacOS/../Frameworks = Contents/Frameworks/ ✓
rsync -a "dist/Frameworks/Chromium Embedded Framework.framework" "$FRAMEWORKS/"

# Backend sidecar — versioned name in exe_dir (sidecar.rs lookup order 1)
cp "dist/bin/agentmux-srv-${VERSION}-darwin.${ARCH}" \
"$MACOS/agentmux-srv-${VERSION}-darwin.${ARCH}"
chmod +x "$MACOS/agentmux-srv-${VERSION}-darwin.${ARCH}"

# Built frontend — IPC server serves from {exe_dir}/frontend/
cp -r dist/frontend "$MACOS/frontend"

# App icon
[ -f build/icon.icns ] && \
cp build/icon.icns "$APP/Contents/Resources/AgentMux.icns"

# Info.plist
BUNDLE_ID="ai.agentmux.app.v$(echo $VERSION | tr '.' '-')"
printf '%s\n' \
'<?xml version="1.0" encoding="UTF-8"?>' \
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
'<plist version="1.0"><dict>' \
" <key>CFBundleIdentifier</key> <string>${BUNDLE_ID}</string>" \
' <key>CFBundleName</key> <string>AgentMux</string>' \
' <key>CFBundleDisplayName</key> <string>AgentMux</string>' \
' <key>CFBundleExecutable</key> <string>agentmux-cef</string>' \
" <key>CFBundleVersion</key> <string>${VERSION}</string>" \
" <key>CFBundleShortVersionString</key> <string>${VERSION}</string>" \
' <key>CFBundleIconFile</key> <string>AgentMux</string>' \
' <key>LSMinimumSystemVersion</key> <string>12.0</string>' \
' <key>NSHighResolutionCapable</key> <true/>' \
' <key>NSRequiresAquaSystemAppearance</key> <false/>' \
' <key>com.apple.security.cs.allow-jit</key> <true/>' \
' <key>com.apple.security.cs.allow-unsigned-executable-memory</key> <true/>' \
' <key>com.apple.security.cs.disable-library-validation</key> <true/>' \
'</dict></plist>' \
> "$APP/Contents/Info.plist"
echo "✓ Built AgentMux.app (v${VERSION})"

- |
VERSION=$(node -p "require('./package.json').version")
xattr -cr dist/AgentMux.app 2>/dev/null || true
DMG="dist/AgentMux_${VERSION}_aarch64.dmg"
hdiutil create -volname "AgentMux ${VERSION}" \
-srcfolder dist/AgentMux.app -ov -format UDZO "$DMG"
xattr -d com.apple.quarantine "$DMG" 2>/dev/null || true
cp "$DMG" ~/Desktop/
echo "✓ DMG → ~/Desktop/AgentMux_${VERSION}_aarch64.dmg"


package:portable:linux:
Expand Down Expand Up @@ -431,7 +495,44 @@ tasks:
internal: true
platforms: [darwin]
cmds:
- echo "macOS CEF bundling not yet implemented"
- |
VERSION=$(node -p "require('./package.json').version")
ARCH="arm64"

# 1. Locate CEF framework in build output
CEF_FW=$(find target -type d -name "Chromium Embedded Framework.framework" \
-path "*/cef-dll-sys*/out/*" 2>/dev/null | head -1)
if [ -z "$CEF_FW" ]; then
echo "❌ Chromium Embedded Framework.framework not found — run cef:build first"
exit 1
fi
echo "Found CEF framework: $CEF_FW"

# 2. Copy framework to dist/Frameworks/
# library_loader resolves {exe_dir}/../Frameworks/ so it must be one level
# above dist/cef/ — i.e. dist/Frameworks/
mkdir -p dist/Frameworks
rsync -a --delete "$CEF_FW" dist/Frameworks/

# 3. Strip all non-English lproj dirs from framework (~50 MB saved)
find "dist/Frameworks/Chromium Embedded Framework.framework/Resources" \
-maxdepth 1 -name "*.lproj" ! -name "en.lproj" -exec rm -rf {} + 2>/dev/null || true

echo "✓ CEF framework → dist/Frameworks/"

- |
VERSION=$(node -p "require('./package.json').version")
ARCH="arm64"

# 4. Versioned sidecar (sidecar.rs looks for {exe_dir}/agentmux-srv-{VERSION}-darwin.arm64)
cp "dist/bin/agentmux-srv-${VERSION}-darwin.${ARCH}" \
"dist/cef/agentmux-srv-${VERSION}-darwin.${ARCH}"

# 5. Built frontend (IPC server serves from {exe_dir}/frontend/)
rm -rf dist/cef/frontend
cp -r dist/frontend dist/cef/frontend

echo "✓ Bundled macOS runtime to dist/cef/"

cef:bundle:linux:
internal: true
Expand Down
11 changes: 11 additions & 0 deletions agentmux-cef/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,17 @@ wrap_app! {
let rpl_key = CefString::from("renderer-process-limit");
let rpl_val = CefString::from("1");
cmd.append_switch_with_value(Some(&rpl_key), Some(&rpl_val));

// Bypass macOS Keychain for Chromium's OSCrypt/SafeStorage.
// Without this, Chromium prompts the user to allow keychain
// access on every launch to store a cookie-encryption key.
// AgentMux doesn't store browser passwords so mock keychain
// is safe and eliminates the OS security dialog entirely.
#[cfg(target_os = "macos")]
{
let mk_key = CefString::from("use-mock-keychain");
cmd.append_switch(Some(&mk_key));
}
}
}

Expand Down
9 changes: 8 additions & 1 deletion agentmux-cef/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ use cef::*;
use std::sync::Arc;
use parking_lot::Mutex;

// The native OS key event type differs per platform. On macOS/Linux it is an
// opaque `*mut u8` (NSEvent* / XEvent*); on Windows it is `cef::sys::MSG`.
#[cfg(windows)]
type NativeKeyEvent = cef::sys::MSG;
#[cfg(not(windows))]
type NativeKeyEvent = u8;

use crate::state::AppState;

/// Write a debug line to `%TEMP%\agentmux-close-debug.txt`.
Expand Down Expand Up @@ -673,7 +680,7 @@ wrap_keyboard_handler! {
&self,
_browser: Option<&mut Browser>,
event: Option<&KeyEvent>,
_os_event: Option<&mut cef::sys::MSG>,
_os_event: *mut NativeKeyEvent,
is_keyboard_shortcut: Option<&mut ::std::os::raw::c_int>,
) -> ::std::os::raw::c_int {
if let Some(ev) = event {
Expand Down
149 changes: 146 additions & 3 deletions agentmux-cef/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ fn main() {

tracing::info!("Initializing CEF browser process");

// macOS 26 Tahoe compat: CEF 146 calls a private NSApplication selector during
// NSDraggingSession setup that was removed in macOS 26. Swizzle
// doesNotRecognizeSelector: on NSApplication to log the selector name and
// return without throwing NSInvalidArgumentException, allowing drag to proceed.
// See: docs/investigations/tab-drag-tearoff-crash-macos.md
#[cfg(target_os = "macos")]
unsafe { patch_nsapp_unrecognized_selector() };

// Single-instance check: if another instance of the same version is
// running, send it a "new window" request via its IPC server and exit.
// Uses a named mutex for detection and a port file for communication.
Expand Down Expand Up @@ -207,14 +215,31 @@ fn main() {
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
.unwrap_or_default();
let runtime_dir = exe_dir.join("runtime");
let base_dir = if runtime_dir.exists() {
let _base_dir = if runtime_dir.exists() {
runtime_dir
} else {
// Dev mode: resources are flat alongside the exe in dist/cef/
exe_dir.clone()
};
let resources_dir = CefString::from(base_dir.to_str().unwrap_or(""));
let locales_dir = CefString::from(base_dir.join("locales").to_str().unwrap_or(""));
// On macOS, pak files and locale paks live inside the CEF framework's
// Resources/ directory — not alongside the executable. The framework is
// at {exe_dir}/../Frameworks/ (resolved by library_loader above).
// Pass that path for both resources_dir and locales_dir so CEF finds
// chrome_*.pak, resources.pak, icudtl.dat, and the *.lproj/locale.pak files.
#[cfg(target_os = "macos")]
let (resources_dir, locales_dir) = {
let fw_resources = exe_dir
.join("../Frameworks/Chromium Embedded Framework.framework/Resources");
// canonicalize() resolves ".." so CEF receives a clean absolute path.
let fw_resources = fw_resources.canonicalize().unwrap_or(fw_resources);
let s = fw_resources.to_str().unwrap_or("").to_owned();
(CefString::from(s.as_str()), CefString::from(s.as_str()))
};
#[cfg(not(target_os = "macos"))]
let (resources_dir, locales_dir) = (
CefString::from(_base_dir.to_str().unwrap_or("")),
CefString::from(_base_dir.join("locales").to_str().unwrap_or("")),
);

// Reuse data_dir from single-instance check as CEF cache path.
// Remove stale lockfile from a previous killed run.
Expand All @@ -227,6 +252,14 @@ fn main() {
let cache_dir = CefString::from(data_dir.to_str().unwrap_or(""));

// Configure CEF settings.
// On macOS, tell CEF exactly where the framework lives so it can load ICU
// and register the bundle correctly — required when running outside a .app.
#[cfg(target_os = "macos")]
let framework_dir = {
let p = exe_dir.join("../Frameworks/Chromium Embedded Framework.framework");
p.canonicalize().unwrap_or(p)
};

let settings = Settings {
no_sandbox: 1,
background_color: 0xFF000000,
Expand All @@ -238,6 +271,8 @@ fn main() {
browser_subprocess_path: CefString::from(
std::env::current_exe().unwrap().to_str().unwrap_or("")
),
#[cfg(target_os = "macos")]
framework_dir_path: CefString::from(framework_dir.to_str().unwrap_or("")),
..Default::default()
};

Expand Down Expand Up @@ -290,6 +325,114 @@ fn main() {
tracing::info!("AgentMux CEF host shutdown complete");
}

/// macOS 26 Tahoe compat: CEF 146 calls private NSApplication selectors (e.g.
/// `isHandlingSendEvent`) during NSDraggingSession setup that were removed in macOS 26.
///
/// The correct fix is to hook `+[NSApplication resolveInstanceMethod:]` — the earliest
/// point in the ObjC dispatch chain — so missing selectors get a void stub before the
/// forwarding machinery (`___forwarding___`) is invoked. Swizzling `doesNotRecognizeSelector:`
/// is wrong here: that method is called FROM inside `___forwarding___`, and returning
/// normally from it (without throwing) corrupts the forwarding state and causes a second
/// crash inside `___forwarding___` itself.
///
/// Return-type-aware stubs: `isHandlingSendEvent` and similar BOOL guard getters must
/// return 0 (NO). A void stub leaves x0 = self (truthy), causing CEF to think the app
/// is already handling a send event and skip normal event routing — breaking window drag.
/// All other unknown selectors get a void stub, which is safe.
///
/// Safety: Called once before CEF initializes. NSApplication is a singleton; adding a
/// `resolveInstanceMethod:` implementation on its metaclass is safe at startup.
#[cfg(target_os = "macos")]
unsafe fn patch_nsapp_unrecognized_selector() {
use std::ffi::{c_char, c_void};

type Id = *mut c_void;
type Sel = *const c_void;
type Class = *mut c_void;

extern "C" {
fn objc_getClass(name: *const c_char) -> Class;
fn object_getClass(obj: Id) -> Class; // on a Class obj → returns metaclass
fn sel_registerName(name: *const c_char) -> Sel;
fn sel_getName(sel: Sel) -> *const c_char;
fn class_addMethod(
cls: Class,
sel: Sel,
imp: usize,
types: *const c_char,
) -> u8; // BOOL
}

// Generic void stub for unknown selectors that return nothing (or whose
// return value is not used by callers).
unsafe extern "C" fn void_stub(_self: Id, _cmd: Sel) {}

// BOOL stub returning 0 (NO) for guard-style getters. On ARM64, a void stub
// leaves x0 = self (non-nil = truthy), which breaks callers like CEF's
// sendEvent: guard that skips event routing when isHandlingSendEvent returns YES.
unsafe extern "C" fn bool_no_stub(_self: Id, _cmd: Sel) -> u8 { 0 }

// +resolveInstanceMethod: injected into NSApplication metaclass.
// Called by the ObjC runtime the first time an unknown selector is sent to
// an NSApplication instance — before ___forwarding___ is ever entered.
// We add a typed stub and return YES so the runtime retries the send.
unsafe extern "C" fn resolve_instance_method_impl(
cls: Class,
_cmd: Sel,
sel: Sel,
) -> u8 {
let name = {
let ptr = sel_getName(sel);
if ptr.is_null() { "<unknown>".to_owned() }
else { std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned() }
};

// These selectors return BOOL and callers act on the value.
// Returning truthy (garbage from a void stub) breaks event routing and
// prevents window drag from receiving mouse events.
const BOOL_NO_SELECTORS: &[&str] = &[
"isHandlingSendEvent",
"isSendingEvent",
];

if BOOL_NO_SELECTORS.contains(&name.as_str()) {
tracing::warn!(selector = %name, "macOS 26 compat: adding BOOL(NO) stub");
class_addMethod(cls, sel, bool_no_stub as usize, b"c@:\0".as_ptr() as _);
} else {
tracing::warn!(selector = %name, "macOS 26 compat: adding void stub");
class_addMethod(cls, sel, void_stub as usize, b"v@:\0".as_ptr() as _);
}
1 // YES — resolved; runtime retries the original send
}

let cls = objc_getClass(b"NSApplication\0".as_ptr() as _);
if cls.is_null() {
tracing::warn!("macOS 26 compat: NSApplication class not found");
return;
}

// The metaclass is the "class object" of a class; class methods live there.
let metacls = object_getClass(cls as Id);
if metacls.is_null() {
tracing::warn!("macOS 26 compat: NSApplication metaclass not found");
return;
}

let sel = sel_registerName(b"resolveInstanceMethod:\0".as_ptr() as _);
// "c@::" = BOOL return, id (Class), SEL (cmd), SEL (queried selector)
let added = class_addMethod(
metacls,
sel,
resolve_instance_method_impl as usize,
b"c@::\0".as_ptr() as _,
);
if added != 0 {
tracing::info!("macOS 26 compat: injected resolveInstanceMethod: into NSApplication metaclass");
} else {
tracing::warn!("macOS 26 compat: class_addMethod failed (method already exists?)");
}
}

/// Initialize tracing with dual output: rolling daily log file + human-readable stderr.
/// Returns a guard that must be held for the lifetime of the process to ensure log flushing.
fn init_logging() -> tracing_appender::non_blocking::WorkerGuard {
Expand Down
Loading