diff --git a/.gitignore b/.gitignore index fa2df1201524..cae1390a341a 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,11 @@ tests/visual_report.html # Local scratch (screenshots, etc.) tmp/ tmp-*/ + +# CEF binary distribution — provisioned by CEF/vendor/fetch_cef.sh +# (~270 MiB compressed, ~1.4 GiB extracted; never commit). +CEF/CEF/ +CEF/Frameworks/ +CEF/.build/ +CEF/.swiftpm/ +CEF/Package.resolved diff --git a/CEF/CEFArtifacts b/CEF/CEFArtifacts new file mode 120000 index 000000000000..2cf2c63071ac --- /dev/null +++ b/CEF/CEFArtifacts @@ -0,0 +1 @@ +Frameworks \ No newline at end of file diff --git a/CEF/INTEGRATION.md b/CEF/INTEGRATION.md new file mode 100644 index 000000000000..3a83ff37d827 --- /dev/null +++ b/CEF/INTEGRATION.md @@ -0,0 +1,77 @@ +# CEF integration notes + +CEF is wired into `GhosttyTabs.xcodeproj` as a local Swift package named +`CMUXCEF`. New browser panes can opt into it through the Debug menu browser +engine selector; WKWebView remains the default. + +## Source checkout setup + +Run the repo setup script from the root: + +```bash +./scripts/setup.sh +``` + +That script: + +1. Initializes submodules. +2. Builds GhosttyKit. +3. Runs `CEF/vendor/fetch_cef.sh` to provision the CEF SDK used by SwiftPM and + the helper build. + +The CEF SDK step downloads the pinned tarball from `vendor/cef.lock.json`, +verifies size and SHA1, extracts it under `CEF/CEF/`, builds +`libcef_dll_wrapper.a`, and populates `CEF/Frameworks/`. + +## Xcode build behavior + +The `Embed CEF` build phase runs `CEF/Scripts/embed_cef_into_cmux.sh`. + +The build phase does not download the SDK. If `CEF/Frameworks/` is missing, it +fails with an explicit setup message. This keeps large network downloads in +`./scripts/setup.sh` instead of hiding them inside a regular Xcode build. + +By default the build phase embeds only the small helper apps. It removes any +bundled `Chromium Embedded Framework.framework` so the app can start without a +large local runtime. CI or release experiments can opt into bundling the +framework with: + +```bash +CMUX_EMBED_CEF_FRAMEWORK=1 ./scripts/reload.sh --tag cef-bundled +``` + +## App runtime behavior + +When CEF is selected from `Debug > Browser Engine` and no runtime is installed, +cmux asks the user for confirmation, downloads the pinned runtime, verifies the +size and SHA1, installs it under Application Support, and starts CEF from that +installed framework. If install or startup fails, cmux leaves WKWebView +available. + +CEF runtime startup and helper execution require macOS 15.0 or later. On older +macOS versions, the browser engine selector falls back to WKWebView. + +The installed runtime is keyed by the app bundle ID, so tagged debug builds are +isolated from each other. A given app bundle ID reuses the runtime on subsequent +launches. + +## Signing and entitlements + +Debug builds use `Resources/cmux.debug.entitlements`. Helper apps get the JIT +and executable-memory entitlements needed by Chromium; development-only helper +entitlements such as `get-task-allow` are added only for Debug helper builds. + +Release builds use `Resources/cmux.entitlements`. Do not add debug-only +entitlements to the release file. + +## Verification + +Use a tagged debug build: + +```bash +./scripts/reload.sh --tag cef-dev +``` + +Then launch the printed `.app`, switch `Debug > Browser Engine > CEF`, and +confirm that the runtime progress window appears only on first use. Switching +back to WKWebView should not remove the installed runtime. diff --git a/CEF/Package.swift b/CEF/Package.swift new file mode 100644 index 000000000000..9ac99051b8a4 --- /dev/null +++ b/CEF/Package.swift @@ -0,0 +1,151 @@ +// swift-tools-version: 6.1 +// +// CMUXCEF — cmux-owned Swift facade for embedding CEF Chrome runtime +// browsers inside cmux. See CEF/README.md and CEF/INTEGRATION.md for +// the local build and app-runtime installation model. +// +// This package is intentionally **isolated**: +// * It depends only on the system AppKit / Foundation modules and on +// the CEF framework + libcef_dll_wrapper.a placed by +// vendor/fetch_cef.sh. +// * It does **not** import anything from cmux.app. +// * It produces a public Swift library (CMUXCEF) plus two helper +// executables (CMUXCEFHelper, CMUXCEFHelperRenderer) that ship as +// embedded helper .app bundles inside cmux.app/Contents/Frameworks/. +// +// The Frameworks/ directory layout consumed here is the one prepared by +// `vendor/fetch_cef.sh`. `./scripts/setup.sh` runs it for cmux source +// checkouts; installed cmux apps download the CEF runtime separately on +// first CEF use. + +import Foundation +import PackageDescription + +// `vendor/fetch_cef.sh` produces: +// /Frameworks/Chromium Embedded Framework.framework +// /Frameworks/libcef_dll_wrapper.a +// /Frameworks/include/ +// We point cxxSettings + linkerSettings at that Frameworks/ directory. +// (Inside the prototype we used a `CEFArtifacts` symlink; in the cmux +// vendored copy we drop straight into `Frameworks/`.) +private let cefFrameworksDir: String = { + let pkgDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + return pkgDir.appendingPathComponent("Frameworks").path +}() + +let package = Package( + name: "CMUXCEF", + platforms: [ + // Keep the package buildable at cmux's deployment target so the app can + // launch and fall back to WKWebView on older hosts. CEFEngine.start() + // and the helper bundles enforce the actual CEF runtime floor: + // macOS 15.0 or later. + .macOS(.v14), + ], + products: [ + .library(name: "CMUXCEF", targets: ["CMUXCEF"]), + .executable(name: "CMUXCEFHelper", targets: ["CMUXCEFHelper"]), + .executable(name: "CMUXCEFHelperRenderer", targets: ["CMUXCEFHelperRenderer"]), + .executable(name: "CMUXCEFDemoApp", targets: ["CMUXCEFDemoApp"]), + ], + targets: [ + // MARK: - Bridge — ObjC++ bridge that owns all CEF C++ interop. + .target( + name: "CMUXCEFBridge", + publicHeadersPath: "include", + cxxSettings: [ + .headerSearchPath("../../Frameworks/include"), + .headerSearchPath("../../Frameworks"), + .unsafeFlags(["-std=c++20"]), + ], + linkerSettings: [ + // Everything in one unsafeFlags block so the linker sees + // -L/-F before -l/-weak_framework. `.linkedLibrary` / + // `.linkedFramework` are not used because SwiftPM/Xcode + // do not preserve their position relative to unsafeFlags. + .unsafeFlags([ + "-L", cefFrameworksDir, + "-F", cefFrameworksDir, + "-lcef_dll_wrapper", + // Keep the CEF framework weak-linked so cmux can launch + // before the optional runtime has been installed. The + // bridge calls cef_load_library() with the resolved + // runtime path before touching CEF APIs. + "-Xlinker", "-weak_framework", + "-Xlinker", "Chromium Embedded Framework", + ]), + ] + ), + + // MARK: - CMUXCEF — Swift facade. The only API cmux app code + // imports. + .target( + name: "CMUXCEF", + dependencies: ["CMUXCEFBridge"] + ), + + // MARK: - Helper executables. Both are tiny — they exist solely + // so CEF's multi-process launcher has a binary to spawn for each + // helper role. Real per-process logic lives in CEF itself. + .executableTarget( + name: "CMUXCEFHelper", + dependencies: ["CMUXCEFBridge"], + sources: ["main.mm"], + cxxSettings: [ + .headerSearchPath("../../Frameworks/include"), + .headerSearchPath("../../Frameworks"), + .unsafeFlags(["-std=c++20"]), + ], + linkerSettings: [ + .linkedFramework("Foundation"), + .linkedFramework("AppKit"), + .unsafeFlags([ + "-L", cefFrameworksDir, + "-F", cefFrameworksDir, + "-Xlinker", "-rpath", "-Xlinker", "@loader_path/../../..", + "-Xlinker", "-rpath", "-Xlinker", "@loader_path/../../../Frameworks", + ]), + ] + ), + .executableTarget( + name: "CMUXCEFHelperRenderer", + dependencies: ["CMUXCEFBridge"], + sources: ["main.mm"], + cxxSettings: [ + .headerSearchPath("../../Frameworks/include"), + .headerSearchPath("../../Frameworks"), + .unsafeFlags(["-std=c++20"]), + ], + linkerSettings: [ + .linkedFramework("Foundation"), + .linkedFramework("AppKit"), + .unsafeFlags([ + "-L", cefFrameworksDir, + "-F", cefFrameworksDir, + "-Xlinker", "-rpath", "-Xlinker", "@loader_path/../../..", + "-Xlinker", "-rpath", "-Xlinker", "@loader_path/../../../Frameworks", + ]), + ] + ), + + // MARK: - Demo / smoke executable. Runs an end-to-end check that + // CEFEngine.start + makeBrowser produce a working Chrome runtime + // window. Not shipped; for local development only. + .executableTarget( + name: "CMUXCEFDemoApp", + dependencies: ["CMUXCEF"], + linkerSettings: [ + .unsafeFlags([ + "-Xlinker", "-rpath", "-Xlinker", cefFrameworksDir, + ]), + ] + ), + + // MARK: - Tests. Behavioral; no source-grep tests. + .testTarget( + name: "CMUXCEFTests", + dependencies: ["CMUXCEF"] + ), + ], + swiftLanguageModes: [.v6] +) diff --git a/CEF/README.md b/CEF/README.md new file mode 100644 index 000000000000..5f189d21c646 --- /dev/null +++ b/CEF/README.md @@ -0,0 +1,78 @@ +# CMUXCEF + +`CEF/` contains cmux's Chromium Embedded Framework integration. The app code +imports only the Swift `CMUXCEF` facade; CEF's C++ API stays behind the ObjC++ +bridge in `Sources/CMUXCEFBridge`. + +## Runtime Model + +cmux keeps the CEF SDK needed for source builds separate from the Chromium +runtime shipped to end users: + +- Source builds use `CEF/vendor/fetch_cef.sh` to download the pinned SDK, + verify it against `vendor/cef.lock.json`, build `libcef_dll_wrapper.a`, + and populate `CEF/Frameworks/`. +- Installed apps do not bundle the large Chromium framework by default. When a + user selects CEF from the Debug menu for the first time, cmux downloads the + same pinned runtime, verifies the size and SHA1, and installs it in + Application Support. +- Subsequent launches reuse the installed runtime for the same app bundle ID. + +This keeps the repository and app bundle small while still making the CEF +runtime opt-in and repeatable. + +CEF runtime startup and helper execution require macOS 15.0 or later. On older +macOS versions, cmux falls back to WKWebView. + +## Local Build + +From the repo root: + +```bash +./scripts/setup.sh +./scripts/reload.sh --tag cef-dev +``` + +`setup.sh` initializes submodules, builds GhosttyKit, and provisions the CEF +SDK for local builds. The CEF tarball is cached under +`~/Library/Caches/cmux-cef-vendor/`, so repeated setup runs are fast. + +To refresh only the CEF SDK: + +```bash +cd CEF +vendor/fetch_cef.sh +``` + +`CEF/CEF/`, `CEF/Frameworks/`, `.build/`, and `Package.resolved` are generated +artifacts and must not be committed. + +## Package Layout + +```text +CEF/ +├── Package.swift +├── Sources/ +│ ├── CMUXCEF/ Swift facade used by cmux.app +│ ├── CMUXCEFBridge/ ObjC++ bridge and public C bridge header +│ ├── CMUXCEFHelper/ Browser helper entrypoint +│ ├── CMUXCEFHelperRenderer/ Renderer helper entrypoint +│ └── CMUXCEFDemoApp/ Local demo executable +├── Tests/ +└── vendor/ + ├── cef.lock.json Authoritative pinned CEF version + ├── cef.lock.schema.json + └── fetch_cef.sh +``` + +## Rules + +- Do not commit CEF binaries or extracted SDK artifacts. +- Treat `vendor/cef.lock.json` as the source of truth for the CEF version, + tarball, SHA1, size, and extracted directory name. +- Keep direct CEF C++ usage inside `CMUXCEFBridge`; Swift code should use the + `CMUXCEF` API. +- Helper apps are embedded into `cmux.app/Contents/Frameworks/` by + `Scripts/embed_cef_into_cmux.sh`. +- Runtime download UI and verification live in the main app so end users do + not need to run provisioning scripts. diff --git a/CEF/Scripts/codesign_dev.sh b/CEF/Scripts/codesign_dev.sh new file mode 100755 index 000000000000..09108b3f7b91 --- /dev/null +++ b/CEF/Scripts/codesign_dev.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# codesign_dev.sh — apply the CEF-required entitlements to the SwiftPM +# build products of this package (CMUXCEFHelper, CMUXCEFHelperRenderer, +# CMUXCEFDemoApp). SwiftPM's auto-generated entitlement plist for +# executable targets only ships `get-task-allow`, which is insufficient +# for CEF Chrome runtime: helpers need V8 JIT, V8 unsigned executable +# memory, and (because the bundled CEF framework is ad-hoc-signed with an +# empty team id) library validation must be disabled. +# +# This script is a **development** convenience for `swift run`. When the +# cmux Xcode project consumes this package, it sets these entitlements +# from its own .entitlements file and re-signs the helpers with the +# cmux Developer ID; this script is no longer in the loop. +# +# Usage: +# ./Scripts/codesign_dev.sh # signs the most-recent debug build +# ./Scripts/codesign_dev.sh release # signs the release build + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PKG_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +CONFIG="${1:-debug}" + +case "${CONFIG}" in + debug|release) ;; + *) + echo "codesign_dev: config must be 'debug' or 'release'" >&2 + exit 2 + ;; +esac + +ARCH="$(uname -m)" +BUILD_DIR="${PKG_DIR}/.build/${ARCH}-apple-macosx/${CONFIG}" + +if [[ ! -d "${BUILD_DIR}" ]]; then + echo "codesign_dev: ${BUILD_DIR} not found. Run 'swift build' first." >&2 + exit 3 +fi + +ENTITLEMENTS="$(mktemp -t cmux-cef-entitlements).plist" +cat > "${ENTITLEMENTS}" <<'PLIST' + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.get-task-allow + + + +PLIST + +sign_one() { + local target="$1" + local path="${BUILD_DIR}/${target}" + if [[ ! -f "${path}" ]]; then + echo "codesign_dev: skip ${target} (not built)" + return 0 + fi + echo "codesign_dev: signing ${target}" + codesign --force --sign - \ + --entitlements "${ENTITLEMENTS}" \ + --options runtime \ + --timestamp=none \ + "${path}" +} + +sign_one CMUXCEFHelper +sign_one CMUXCEFHelperRenderer +sign_one CMUXCEFDemoApp + +rm -f "${ENTITLEMENTS}" + +echo "codesign_dev: done" diff --git a/CEF/Scripts/embed_cef_into_cmux.sh b/CEF/Scripts/embed_cef_into_cmux.sh new file mode 100755 index 000000000000..522339b0ca74 --- /dev/null +++ b/CEF/Scripts/embed_cef_into_cmux.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Embed the Chromium Embedded Framework + helper .app bundles into cmux.app/Contents/Frameworks/. +# Invoked as a "Run Script" build phase on the GhosttyTabs target. +# +# Expected env vars: SRCROOT, BUILT_PRODUCTS_DIR, EXECUTABLE_FOLDER_PATH, CONFIGURATION, +# EXPANDED_CODE_SIGN_IDENTITY (Xcode sets these for build phases). + +set -euo pipefail + +CEF_ROOT="${SRCROOT}/CEF" +FRAMEWORK_SRC="${CEF_ROOT}/Frameworks/Chromium Embedded Framework.framework" +FW_DIR="${BUILT_PRODUCTS_DIR}/${EXECUTABLE_FOLDER_PATH}/../Frameworks" + +if [[ ! -d "${FRAMEWORK_SRC}" ]]; then + echo "error: CEF framework not provisioned at ${FRAMEWORK_SRC}" >&2 + echo "error: run ./scripts/setup.sh or CEF/vendor/fetch_cef.sh before building cmux from source." >&2 + exit 1 +fi + +mkdir -p "${FW_DIR}" + +# 1. Optionally rsync the Chromium Embedded Framework into Frameworks/. +# By default cmux ships only the small helper apps; the Debug menu downloads +# and verifies the CEF runtime on first use. CI/release jobs can opt back into +# a fully bundled runtime with CMUX_EMBED_CEF_FRAMEWORK=1. +if [[ "${CMUX_EMBED_CEF_FRAMEWORK:-0}" == "1" ]]; then + rsync -a --delete \ + "${FRAMEWORK_SRC}" \ + "${FW_DIR}/" +else + rm -rf "${FW_DIR}/Chromium Embedded Framework.framework" + echo "embed_cef_into_cmux: skipping bundled CEF framework; runtime installs on first use." +fi + +# 2. Build the helper executables. +SWIFT_CFG=$(echo "${CONFIGURATION:-Debug}" | tr '[:upper:]' '[:lower:]') +case "${SWIFT_CFG}" in + debug|release) ;; + *) SWIFT_CFG="debug" ;; +esac + +(cd "${CEF_ROOT}" && xcrun swift build -c "${SWIFT_CFG}" --product CMUXCEFHelper) +(cd "${CEF_ROOT}" && xcrun swift build -c "${SWIFT_CFG}" --product CMUXCEFHelperRenderer) + +BUILD_BIN="$(cd "${CEF_ROOT}" && xcrun swift build -c "${SWIFT_CFG}" --show-bin-path)" +HELPER_BIN="${BUILD_BIN}/CMUXCEFHelper" +RENDERER_BIN="${BUILD_BIN}/CMUXCEFHelperRenderer" + +if [[ ! -x "${HELPER_BIN}" ]] || [[ ! -x "${RENDERER_BIN}" ]]; then + echo "error: missing helper binaries (${HELPER_BIN} / ${RENDERER_BIN}); swift build failed?" + exit 1 +fi + +# 3. Write helper entitlements. Debug helpers get extra development-only +# relaxations; Release helpers keep only Chromium's runtime requirements. +ENT="$(mktemp -t cmux_cef_helper_ent).plist" +cat > "${ENT}" < + + + + com.apple.security.cs.allow-jit + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-executable-page-protection +$(if [[ "${SWIFT_CFG}" == "debug" ]]; then cat <<'DEBUG_ENTITLEMENTS' + com.apple.security.get-task-allow + com.apple.security.cs.disable-library-validation + com.apple.security.cs.allow-dyld-environment-variables +DEBUG_ENTITLEMENTS +fi) + + +EOF + +write_plist() { + local plist="$1" bid="$2" name="$3" exe="$4" + cat > "${plist}" < + + + + CFBundleDevelopmentRegionen + CFBundleExecutable${exe} + CFBundleIdentifier${bid} + CFBundleName${name} + CFBundlePackageTypeAPPL + CFBundleShortVersionString1.0 + CFBundleVersion1 + LSMinimumSystemVersion15.0 + + +EOF +} + +install_helper() { + local folder="$1" exe_src="$2" exe_name="$3" bid="$4" name="$5" + local app="${FW_DIR}/${folder}.app" + rm -rf "${app}" + mkdir -p "${app}/Contents/MacOS" + cp -f "${exe_src}" "${app}/Contents/MacOS/${exe_name}" + chmod +x "${app}/Contents/MacOS/${exe_name}" + write_plist "${app}/Contents/Info.plist" "${bid}" "${name}" "${exe_name}" + + local identity="${EXPANDED_CODE_SIGN_IDENTITY:--}" + codesign --remove-signature "${app}/Contents/MacOS/${exe_name}" 2>/dev/null || true + codesign --remove-signature "${app}" 2>/dev/null || true + codesign --force --sign "${identity}" --entitlements "${ENT}" \ + --timestamp=none --options runtime "${app}/Contents/MacOS/${exe_name}" + codesign --force --sign "${identity}" --entitlements "${ENT}" \ + --timestamp=none --options runtime "${app}" +} + +# Helper bundle IDs must be .helper[.gpu|.renderer|.plugin] for +# Chromium / Chrome runtime on macOS. The OS uses this exact-prefix lookup to +# route helper-to-app Mach IPC; with a mismatched prefix +# (e.g. com.cmux.helper.renderer when the app is com.cmuxterm.app.debug) +# the renderer launches but never establishes its GPU / compositor channel, +# so navigation begins, the renderer parses HTML, but no IOSurface frames +# ever ship — the pane stays transparent. +APP_BID="${PRODUCT_BUNDLE_IDENTIFIER:-com.cmuxterm.app.debug}" + +install_helper "cmux Helper" "${HELPER_BIN}" "cmux Helper" "${APP_BID}.helper" "cmux Helper" +install_helper "cmux Helper (GPU)" "${HELPER_BIN}" "cmux Helper (GPU)" "${APP_BID}.helper.gpu" "cmux Helper GPU" +install_helper "cmux Helper (Renderer)" "${RENDERER_BIN}" "cmux Helper (Renderer)" "${APP_BID}.helper.renderer" "cmux Helper Renderer" + +rm -f "${ENT}" +echo "embed_cef_into_cmux: done." diff --git a/CEF/Sources/CMUXCEF/CEFBrowser.swift b/CEF/Sources/CMUXCEF/CEFBrowser.swift new file mode 100644 index 000000000000..dfa6a787fcaf --- /dev/null +++ b/CEF/Sources/CMUXCEF/CEFBrowser.swift @@ -0,0 +1,223 @@ +import AppKit +import Foundation +import CMUXCEFBridge + +/// Receives navigation and document lifecycle callbacks for a single +/// `CEFBrowser`. All methods are invoked on the `MainActor`. +@MainActor +public protocol CEFBrowserDelegate: AnyObject { + func cefBrowserDidStartLoading(_ browser: CEFBrowser) + func cefBrowserDidFinishLoading(_ browser: CEFBrowser) + func cefBrowser(_ browser: CEFBrowser, didChangeTitle title: String) + func cefBrowser(_ browser: CEFBrowser, didChangeURL url: URL) + func cefBrowser(_ browser: CEFBrowser, didFailLoad error: Error) +} + +public extension CEFBrowserDelegate { + func cefBrowserDidStartLoading(_ browser: CEFBrowser) {} + func cefBrowserDidFinishLoading(_ browser: CEFBrowser) {} + func cefBrowser(_ browser: CEFBrowser, didChangeTitle title: String) {} + func cefBrowser(_ browser: CEFBrowser, didChangeURL url: URL) {} + func cefBrowser(_ browser: CEFBrowser, didFailLoad error: Error) {} +} + +/// A single CEF browser instance. Two embedding modes: +/// * `hostingWindow` — top-level NSWindow path. cmux glues it via +/// `parent.addChildWindow(_, ordered: .above)` + frame tracking. +/// * `embeddableView` — Path B: an NSView extracted from CEF's internal +/// `CefBrowserView`. The cmux pane controller `addSubview`s this into +/// a regular NSView container (NSSplitView, Bonsplit, etc.) just like +/// a WKWebView. +/// Exactly one of these is non-nil per browser. +/// +/// Created via `CEFEngine.shared.makeBrowser(profile:initialURL:)` (legacy +/// child-window mode) or `CEFEngine.shared.makeEmbeddableBrowser( +/// profile:initialURL:)` (Path B, NSView embedding). +@MainActor +public final class CEFBrowser: NSObject { + + /// Non-nil when this browser was created via `makeBrowser` (top-level + /// NSWindow / `addChildWindow` integration). + public let hostingWindow: NSWindow? + + /// Non-nil when this browser was created via `makeEmbeddableBrowser` + /// (NSView extracted from `CefBrowserView` so cmux can `addSubview` it + /// directly into an existing NSView hierarchy). + public let embeddableView: NSView? + + public weak var delegate: CEFBrowserDelegate? + + public var currentTitle: String? { bridge.currentTitle } + public var currentURL: URL? { bridge.currentURL } + public var isLoading: Bool { bridge.isLoading } + public var canGoBack: Bool { bridge.canGoBack } + public var canGoForward: Bool { bridge.canGoForward } + + private let bridge: CMUXCEFBrowserBridge + + init(bridge: CMUXCEFBrowserBridge) { + self.bridge = bridge + self.hostingWindow = bridge.hostingWindow + self.embeddableView = bridge.embeddableView + super.init() + bridge.delegate = self + } + + /// For embedded browsers: keep CEF's internal CefWindow at the same + /// on-screen rectangle as the container hosting `embeddableView`. + /// CEF clips its render canvas + computes mouse hit-test from the + /// CefWindow frame, so the two must stay aligned. Call after any + /// resize / divider drag / window move / screen change. + public func syncRenderFrame(toScreen rect: NSRect) { + bridge.syncRenderFrame(toScreenRect: rect) + } + + /// Tell the bridge which cmux NSWindow this browser should be hosted + /// on via `addChildWindow:`. Must be called before `syncRenderFrame` + /// for the overlay to attach. + public func attach(toHost window: NSWindow) { + bridge.attach(toHostWindow: window) + } + + /// Notify Chromium that the embedded view has been reparented / resized + /// so it re-queries view size and pushes a fresh render layer. + public func notifyEmbedHostResizedAndShown() { + bridge.notifyEmbedHostResizedAndShown() + } + + public func load(_ url: URL) { bridge.load(url) } + public func goBack() { bridge.goBack() } + public func goForward() { bridge.goForward() } + public func reload() { bridge.reload() } + public func stopLoading() { bridge.stopLoading() } + public func showDevTools() { bridge.showDevTools() } + public func closeDevTools() { bridge.closeDevTools() } + public func close() { + bridge.delegate = nil + bridge.close() + } +} + +extension CEFBrowser: @MainActor CMUXCEFBrowserBridgeDelegate { + public func browserBridgeDidStartLoading(_ bridge: CMUXCEFBrowserBridge) { + delegate?.cefBrowserDidStartLoading(self) + } + + public func browserBridgeDidFinishLoading(_ bridge: CMUXCEFBrowserBridge) { + delegate?.cefBrowserDidFinishLoading(self) + } + + public func browserBridge( + _ bridge: CMUXCEFBrowserBridge, + didChangeTitle title: String + ) { + delegate?.cefBrowser(self, didChangeTitle: title) + } + + public func browserBridge( + _ bridge: CMUXCEFBrowserBridge, + didChange url: URL + ) { + delegate?.cefBrowser(self, didChangeURL: url) + } + + public func browserBridge( + _ bridge: CMUXCEFBrowserBridge, + didFailLoad error: Error + ) { + delegate?.cefBrowser(self, didFailLoad: error) + } +} + +// MARK: - Engine factory + +public extension CEFEngine { + + /// Create a new CEF browser inside its own borderless top-level + /// `NSWindow`, using `profile`'s `CefRequestContext`. + /// + /// The returned browser's `hostingWindow` is created hidden. The + /// caller (a cmux pane controller) is responsible for: + /// 1. `parent.addChildWindow(browser.hostingWindow, ordered: .above)` + /// 2. Tracking the pane placeholder's screen-space frame and calling + /// `browser.hostingWindow.setFrame(_:display:)` to follow it. + /// 3. `browser.hostingWindow.orderFront(nil)` once positioned. + /// 4. `orderOut` (not `close`) when the pane is hidden, so the + /// underlying CEF browser stays alive. + /// 5. `browser.close()` exactly once when the pane is destroyed. + func makeBrowser( + profile: CEFProfile, + initialURL: URL + ) throws -> CEFBrowser { + guard isRunning else { + throw CEFEngineError.cefInitializeFailed( + message: "CEFEngine.start has not been called yet") + } + let bridge: CMUXCEFBrowserBridge + do { + bridge = try CMUXCEFEngineBridge.shared().createBrowser( + inProfile: profile.underlyingBridge, + initialURL: initialURL) + } catch { + throw CEFEngineError.bridge(error as NSError) + } + return CEFBrowser(bridge: bridge) + } + + /// Path B — create a browser whose underlying NSView can be embedded + /// directly into a cmux NSView hierarchy (e.g., a Bonsplit pane or a + /// vanilla NSSplitView). The returned `CEFBrowser` exposes the NSView + /// via `embeddableView`. `hostingWindow` is `nil` in this mode. + func makeEmbeddableBrowser( + profile: CEFProfile, + initialURL: URL + ) throws -> CEFBrowser { + guard isRunning else { + throw CEFEngineError.cefInitializeFailed( + message: "CEFEngine.start has not been called yet") + } + let bridge: CMUXCEFBrowserBridge + do { + bridge = try CMUXCEFEngineBridge.shared().createEmbeddableBrowser( + inProfile: profile.underlyingBridge, + initialURL: initialURL) + } catch { + throw CEFEngineError.bridge(error as NSError) + } + return CEFBrowser(bridge: bridge) + } + + /// **Path C — Alloy runtime native NSView embedding.** + /// + /// Asks CEF to create the browser AS A SUBVIEW of `parentView` (via + /// `CefWindowInfo::SetAsChild`). CEF 146 routes this through Alloy + /// runtime automatically — no CefBrowserView, no CefWindow, no + /// addChildWindow gymnastics. The CEF NSView behaves like any other + /// AppKit subview. + /// + /// Use this when extension support is not required (Alloy in CEF 146 + /// does not expose the Chrome extension subsystem). Rendering, JS, + /// network, devtools all remain full Chromium. + func makeAlloyBrowser( + parentView: NSView, + bounds: NSRect, + profile: CEFProfile, + initialURL: URL + ) throws -> CEFBrowser { + guard isRunning else { + throw CEFEngineError.cefInitializeFailed( + message: "CEFEngine.start has not been called yet") + } + let bridge: CMUXCEFBrowserBridge + do { + bridge = try CMUXCEFEngineBridge.shared().createAlloyBrowser( + withParentView: parentView, + bounds: bounds, + profile: profile.underlyingBridge, + initialURL: initialURL) + } catch { + throw CEFEngineError.bridge(error as NSError) + } + return CEFBrowser(bridge: bridge) + } +} diff --git a/CEF/Sources/CMUXCEF/CEFEngine.swift b/CEF/Sources/CMUXCEF/CEFEngine.swift new file mode 100644 index 000000000000..baeab41a3270 --- /dev/null +++ b/CEF/Sources/CMUXCEF/CEFEngine.swift @@ -0,0 +1,125 @@ +import AppKit +import Foundation +import CMUXCEFBridge + +/// Engine errors. Domain `CMUXCEF` codes mirror `CMUXCEFInitError` from the +/// ObjC++ bridge. +public enum CEFEngineError: Error, Sendable, Equatable { + case alreadyInitialized + case missingRootCachePath + case cefInitializeFailed(message: String?) + case unsupportedOperatingSystem(minimum: String) + case bridge(NSError) +} + +/// Process-wide CEF lifecycle. There is exactly one engine per cmux +/// process; the singleton is acquired through `CEFEngine.shared`. +/// +/// Usage from cmux's app delegate: +/// +/// ```swift +/// // 1. In main(), before AppKit boots, route subprocess invocations. +/// let exitCode = CEFEngine.executeSubprocessIfNeeded() +/// if exitCode >= 0 { exit(exitCode) } +/// +/// // 2. In applicationDidFinishLaunching: +/// try CEFEngine.shared.start(config: .init( +/// rootCachePath: rootCacheURL, +/// extensionDirectories: cmux.curatedExtensionURLs)) +/// +/// // 3. In applicationWillTerminate: +/// CEFEngine.shared.shutdown() +/// ``` +/// +/// Threading: every public method is `MainActor`. The ObjC++ bridge dispatches +/// CEF UI thread tasks where required. +@MainActor +public final class CEFEngine { + + public static let shared = CEFEngine() + + private let cefBridge = CMUXCEFEngineBridge.shared() + private(set) public var config: CEFEngineConfig? + private var hasShutdown = false + + private init() {} + + /// Route the current process to a CEF helper loop when invoked as such + /// by CEF's multi-process launcher. The cmux app's `main()` MUST call + /// this first. The returned value is either an exit code (caller exits + /// immediately) or `-1` (caller continues normal browser-process + /// startup). + /// + /// This is `nonisolated` because main() runs before any actor is set up. + public nonisolated static func executeSubprocessIfNeeded() -> Int32 { + let argv = CommandLine.unsafeArgv + let argc = Int32(CommandLine.arguments.count) + let code = Int32(CMUXCEFEngineBridge.executeSubprocessIfNeeded(argc: argc, argv: argv)) + return code + } + + /// Initialize CEF. Throws `.alreadyInitialized` if called twice in the + /// same process. + public func start(config: CEFEngineConfig) throws { + guard #available(macOS 15.0, *) else { + throw CEFEngineError.unsupportedOperatingSystem(minimum: "macOS 15.0") + } + guard !hasShutdown, self.config == nil else { + throw CEFEngineError.alreadyInitialized + } + + let bridgeConfig = CMUXCEFEngineConfigBridge() + bridgeConfig.rootCachePath = config.rootCachePath.path + bridgeConfig.loadExtensionsArg = Self.serializeLoadExtensionsArg( + config.extensionDirectories) + bridgeConfig.logSeverity = config.logSeverity + bridgeConfig.userAgentProduct = config.userAgentProduct + bridgeConfig.frameworkDirectoryPath = config.frameworkDirectoryPath?.path + bridgeConfig.browserSubprocessPath = config.browserSubprocessPath?.path + + do { + try cefBridge.initialize(withConfig: bridgeConfig) + } catch { + let nsError = error as NSError + if nsError.domain == "CMUXCEF" { + switch nsError.code { + case 1: throw CEFEngineError.alreadyInitialized + case 2: throw CEFEngineError.missingRootCachePath + case 3: throw CEFEngineError.cefInitializeFailed( + message: nsError.localizedDescription) + default: break + } + } + throw CEFEngineError.bridge(nsError) + } + self.config = config + } + + /// Idempotent. After shutdown, calling `start` again in the same process + /// is undefined behaviour (CEF doesn't support `CefInitialize` twice). + public func shutdown() { + guard config != nil else { return } + cefBridge.shutdown() + config = nil + hasShutdown = true + } + + public var isRunning: Bool { cefBridge.isInitialized } + + /// Block on the CEF UI message loop. cmux app should NOT use this; + /// it's intended for CLI integration tests (`swift run`) that don't + /// otherwise run an AppKit event loop. + public func runMessageLoop() { + cefBridge.runMessageLoop() + } + + /// Unblock `runMessageLoop`. + public func quitMessageLoop() { + cefBridge.quitMessageLoop() + } + + private static func serializeLoadExtensionsArg(_ urls: [URL]) -> String? { + guard !urls.isEmpty else { return nil } + return urls.map(\.path).joined(separator: ",") + } +} diff --git a/CEF/Sources/CMUXCEF/CEFEngineConfig.swift b/CEF/Sources/CMUXCEF/CEFEngineConfig.swift new file mode 100644 index 000000000000..e973548270c1 --- /dev/null +++ b/CEF/Sources/CMUXCEF/CEFEngineConfig.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Caller-supplied configuration for `CEFEngine.start`. +/// +/// The configuration is immutable once `CEFEngine.start` has been called. +/// To change any of these values, cmux must restart the process. +public struct CEFEngineConfig: Sendable, Equatable { + + /// Absolute path to the root cache directory. cmux owns this directory. + /// All per-profile cache directories live underneath it. + /// + /// Recommended: `~/Library/Application Support//CEFRoot`. + public let rootCachePath: URL + + /// Absolute filesystem paths to unpacked Chrome extensions to load at + /// process startup. CEF Chrome runtime serializes these into a single + /// `--load-extension` command-line switch passed to Chromium. All + /// profiles share the same available extension set; per-profile state + /// (storage, cookies, login, options-page settings) stays isolated. + public let extensionDirectories: [URL] + + /// CEF log severity. 0 = default, 1 = verbose, 2 = info, 3 = warning, + /// 4 = error, 5 = fatal. + public let logSeverity: Int + + /// Optional product suffix shown in user-agent and chrome://version. + public let userAgentProduct: String? + + /// Optional. Directory holding `Chromium Embedded Framework.framework` + /// and the helper .app bundles. Leave nil when cmux is running inside + /// a proper .app bundle; supply explicitly when running as a CLI + /// (e.g. `swift run`) so CEF can locate `icudtl.dat` and helpers. + public let frameworkDirectoryPath: URL? + + /// Optional. Path to the CEF helper executable to spawn for + /// renderer/GPU/utility processes. Required when running outside an + /// .app bundle. + public let browserSubprocessPath: URL? + + public init( + rootCachePath: URL, + extensionDirectories: [URL] = [], + logSeverity: Int = 0, + userAgentProduct: String? = nil, + frameworkDirectoryPath: URL? = nil, + browserSubprocessPath: URL? = nil + ) { + self.rootCachePath = rootCachePath + self.extensionDirectories = extensionDirectories + self.logSeverity = logSeverity + self.userAgentProduct = userAgentProduct + self.frameworkDirectoryPath = frameworkDirectoryPath + self.browserSubprocessPath = browserSubprocessPath + } +} diff --git a/CEF/Sources/CMUXCEF/CEFProfile.swift b/CEF/Sources/CMUXCEF/CEFProfile.swift new file mode 100644 index 000000000000..a8605d735384 --- /dev/null +++ b/CEF/Sources/CMUXCEF/CEFProfile.swift @@ -0,0 +1,68 @@ +import Foundation +import CMUXCEFBridge + +/// A cmux-named CEF profile, backed by one `CefRequestContext` and one +/// on-disk cache directory. +@MainActor +public final class CEFProfile { + + /// The cmux-side name (e.g. "default", "work", "isolated-"). + /// Must be non-empty. Names are normalized to a path-safe form by the + /// bridge; collisions on the normalized form are not detected. + public let name: String + + /// Absolute path to the on-disk cache directory used by this profile. + public let cachePath: URL + + /// True for profiles whose name begins with `"isolated-"`. These are + /// torn down (cache dir removed) when their last referencing pane closes. + public var isEphemeral: Bool { name.hasPrefix("isolated-") } + + fileprivate let bridge: CMUXCEFProfileBridge + + fileprivate init(bridge: CMUXCEFProfileBridge) { + self.name = bridge.name + self.cachePath = URL(fileURLWithPath: bridge.cachePath) + self.bridge = bridge + } +} + +/// Process-wide profile registry. Same name returns the same `CEFProfile` +/// instance for the lifetime of the cmux process. +@MainActor +public final class CEFProfileRegistry { + + public static let shared = CEFProfileRegistry() + + private let bridge = CMUXCEFProfileRegistryBridge.shared() + private var byName: [String: CEFProfile] = [:] + + private init() {} + + /// Acquire the profile bridge for `name`, creating it (and its cache + /// directory) on first call. + public func profile(named name: String) -> CEFProfile { + precondition(!name.isEmpty, "profile name must be non-empty") + if let cached = byName[name] { + return cached + } + let bridge = self.bridge.profile(forName: name) + let profile = CEFProfile(bridge: bridge) + byName[name] = profile + return profile + } + + /// Release the profile. For ephemeral profiles the cache directory is + /// scheduled for removal a short time after the underlying + /// `CefRequestContext` is destroyed. Named profiles keep their cache + /// directory on disk. + public func release(name: String) { + byName.removeValue(forKey: name) + bridge.destroyProfile(forName: name) + } +} + +extension CEFProfile { + /// Internal access used by `CEFBrowser`. + var underlyingBridge: CMUXCEFProfileBridge { bridge } +} diff --git a/CEF/Sources/CMUXCEFBridge/CMUXCEFBridge.mm b/CEF/Sources/CMUXCEFBridge/CMUXCEFBridge.mm new file mode 100644 index 000000000000..514a7ebf6197 --- /dev/null +++ b/CEF/Sources/CMUXCEFBridge/CMUXCEFBridge.mm @@ -0,0 +1,1232 @@ +// CMUXCEFBridge.mm — ObjC++ implementation of the cmux ⇄ CEF bridge. +// +// Layering rules: +// * Only this file (and the helper main.mm files) include CEF C++ headers. +// * Swift never sees a CefRefPtr or CefBaseRefCounted; only Foundation / +// AppKit types declared in CMUXCEFBridge.h. +// * Every callback that crosses back into Swift is dispatched on the main +// thread. +// +// The bridge currently implements: +// * engine lifecycle (CefInitialize / CefShutdown) +// * subprocess routing (CefExecuteProcess via CMUXCEFEngineBridge +// +executeSubprocessIfNeededWithArgc:argv:) +// * CefApp subclass that forwards cmux configuration into the Chromium +// command line on every browser-process spin-up +// * profile registry that wraps CefRequestContext with cmux-friendly +// names ("default", "work", "isolated-") +// +// Pending (tracked in DESIGN.md §9 Step 2..6): +// * actual browser creation via CefBrowserView + CefWindow (top-level +// borderless NSWindow per pane) +// * CefClient subclass plumbing the load / title / navigation callbacks +// back into CMUXCEFBrowserBridgeDelegate +// * DevTools wiring +// These are tagged `CMUX_TODO`. + +#import "include/CMUXCEFBridge.h" + +#include +#include +#include +#include +#include +#include + +#import +#import + +// CEF includes. Keep these confined to .mm files. +#include "include/cef_app.h" +#include "include/wrapper/cef_library_loader.h" +#include "include/cef_browser.h" +#include "include/cef_client.h" +#include "include/cef_command_line.h" +#include "include/cef_display_handler.h" +#include "include/cef_life_span_handler.h" +#include "include/cef_load_handler.h" +#include "include/cef_request_context.h" +#include "include/cef_request_context_handler.h" +#include "include/views/cef_browser_view.h" +#include "include/views/cef_browser_view_delegate.h" +#include "include/views/cef_window.h" +#include "include/views/cef_window_delegate.h" +#include "include/wrapper/cef_helpers.h" + +// MARK: - Constants + +static NSString * const kCMUXCEFErrorDomain = @"CMUXCEF"; +static NSString * const kCMUXCEFFrameworksDirEnv = @"CMUX_CEF_FRAMEWORKS_DIR"; + +static os_log_t cmuxCEFLog(void) { + static os_log_t log = os_log_create("co.manaflow.cmux", "CMUXCEF"); + return log; +} + +static std::atomic_bool gCMUXCEFMessagePumpRunning{false}; + +static void CMUXCEFRunMessagePumpWorkIfIdle(void) { + bool expected = false; + if (!gCMUXCEFMessagePumpRunning.compare_exchange_strong(expected, true)) { + return; + } + CefDoMessageLoopWork(); + gCMUXCEFMessagePumpRunning.store(false); +} + +static void CMUXCEFPostMessagePumpWork(int64_t delay_ms) { + dispatch_time_t when = (delay_ms <= 0) + ? DISPATCH_TIME_NOW + : dispatch_time(DISPATCH_TIME_NOW, delay_ms * NSEC_PER_MSEC); + dispatch_after(when, dispatch_get_main_queue(), ^{ + CMUXCEFRunMessagePumpWorkIfIdle(); + }); +} + +typedef NS_ENUM(NSInteger, CMUXCEFInitError) { + CMUXCEFInitErrorAlreadyInitialized = 1, + CMUXCEFInitErrorMissingRootCachePath = 2, + CMUXCEFInitErrorCefInitializeFailed = 3, +}; + +static NSString *CMUXCEFFrameworkBinaryPath(NSString *frameworksDir) { + return [frameworksDir stringByAppendingPathComponent: + @"Chromium Embedded Framework.framework/Chromium Embedded Framework"]; +} + +static NSString *CMUXCEFResolveFrameworkBinaryPathFromArgv0(const char *argv0) { + NSString *envPath = [NSProcessInfo processInfo].environment[kCMUXCEFFrameworksDirEnv]; + if (envPath.length > 0) { + return CMUXCEFFrameworkBinaryPath(envPath); + } + + if (argv0) { + NSString *executablePath = [[NSString stringWithUTF8String:argv0] stringByStandardizingPath]; + if (executablePath.length > 0) { + NSURL *url = [NSURL fileURLWithPath:executablePath]; + + // Packaged cmux browser process: + // cmux.app/Contents/MacOS/cmux + NSURL *contentsURL = [[url URLByDeletingLastPathComponent] + URLByDeletingLastPathComponent]; + NSString *mainAppCandidate = + CMUXCEFFrameworkBinaryPath([contentsURL.path stringByAppendingPathComponent:@"Frameworks"]); + if ([[NSFileManager defaultManager] fileExistsAtPath:mainAppCandidate]) { + return mainAppCandidate; + } + + // Packaged cmux helpers: + // cmux.app/Contents/Frameworks/cmux Helper.app/Contents/MacOS/... + NSURL *helperFrameworksURL = [[[[url URLByDeletingLastPathComponent] + URLByDeletingLastPathComponent] + URLByDeletingLastPathComponent] + URLByDeletingLastPathComponent]; + NSString *helperCandidate = CMUXCEFFrameworkBinaryPath(helperFrameworksURL.path); + if ([[NSFileManager defaultManager] fileExistsAtPath:helperCandidate]) { + return helperCandidate; + } + } + } + + // SwiftPM demo/dev fallback. CMUXCEFDemoApp is normally launched from + // the package root and keeps CEFArtifacts -> Frameworks there. + NSString *cwd = [[NSFileManager defaultManager] currentDirectoryPath]; + for (NSString *dirName in @[@"CEFArtifacts", @"Frameworks"]) { + NSString *candidate = CMUXCEFFrameworkBinaryPath([cwd stringByAppendingPathComponent:dirName]); + if ([[NSFileManager defaultManager] fileExistsAtPath:candidate]) { + return candidate; + } + } + + return nil; +} + +static BOOL CMUXCEFEnsureFrameworkLoaded(NSString *frameworkBinaryPath, NSError **error) { + static std::mutex mutex; + static BOOL loaded = NO; + static NSString *loadedPath = nil; + + std::lock_guard lock(mutex); + if (loaded) { + return YES; + } + + if (frameworkBinaryPath.length == 0 || + ![[NSFileManager defaultManager] fileExistsAtPath:frameworkBinaryPath]) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain + code:CMUXCEFInitErrorCefInitializeFailed + userInfo:@{NSLocalizedDescriptionKey: [NSString + stringWithFormat:@"CEF framework binary not found at %@", + frameworkBinaryPath ?: @""]}]; + } + return NO; + } + + if (!cef_load_library([frameworkBinaryPath UTF8String])) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain + code:CMUXCEFInitErrorCefInitializeFailed + userInfo:@{NSLocalizedDescriptionKey: [NSString + stringWithFormat:@"cef_load_library failed at %@", + frameworkBinaryPath]}]; + } + return NO; + } + + loadedPath = [frameworkBinaryPath copy]; + loaded = YES; + os_log(cmuxCEFLog(), "CEF framework loaded from %{public}@", loadedPath); + return YES; +} + +// MARK: - CefApp subclass (browser process) + +namespace { + +class CMUXCEFApp final : public CefApp, public CefBrowserProcessHandler { +public: + explicit CMUXCEFApp(NSString * _Nullable loadExtensionsArg, + NSString * _Nullable userAgentProduct) + : load_extensions_(loadExtensionsArg ? [loadExtensionsArg UTF8String] : ""), + user_agent_product_(userAgentProduct ? [userAgentProduct UTF8String] : "") {} + + // CefApp: + CefRefPtr GetBrowserProcessHandler() override { return this; } + + void OnBeforeCommandLineProcessing(const CefString& process_type, + CefRefPtr command_line) override { + // Forward cmux-curated extension list. Process-global; per-profile + // state isolation handled by per-CefRequestContext cache_path. + if (!load_extensions_.empty() && !command_line->HasSwitch("load-extension")) { + command_line->AppendSwitchWithValue("load-extension", load_extensions_); + } + // chrome:// URLs need this to expose extension pages, devtools-on-self, etc. + if (!command_line->HasSwitch("extensions-on-chrome-urls")) { + command_line->AppendSwitch("extensions-on-chrome-urls"); + } + // Dev builds: the ad-hoc-signed CEF framework and helpers cannot + // satisfy macOS GPU sandbox prerequisites. Production cmux builds + // re-sign with Developer ID and these switches go away. + if (!command_line->HasSwitch("no-sandbox")) { + command_line->AppendSwitch("no-sandbox"); + } + if (!command_line->HasSwitch("disable-gpu-sandbox")) { + command_line->AppendSwitch("disable-gpu-sandbox"); + } + // Without a fully-integrated app bundle the GPU helper can't find + // ANGLE's libGLESv2.dylib. CPU compositing is fine for dev. + if (!command_line->HasSwitch("disable-gpu")) { + command_line->AppendSwitch("disable-gpu"); + } + if (!command_line->HasSwitch("disable-gpu-compositing")) { + command_line->AppendSwitch("disable-gpu-compositing"); + } + } + + // CefBrowserProcessHandler: + void OnContextInitialized() override { + // Nothing per-context here — cmux is the one creating browsers. + } + + // Called by CEF from its UI thread whenever it wants the host to + // drain `CefDoMessageLoopWork()` after `delay_ms`. We hop to the + // main run loop and schedule one pump there. With + // `external_message_pump = true` the entire CEF UI message queue + // (including helper-process Mojo bootstrap invitations) is gated on + // this — no pump = wedged helpers = the 15s timeout we kept seeing. + void OnScheduleMessagePumpWork(int64_t delay_ms) override { + // Never call CefDoMessageLoopWork inline from this callback. + // Chromium may schedule more pump work while AppKit is still in + // synchronous focus / mouse handling. Re-entering the pump in + // that stack can deadlock inside CEF's internal mutexes. + CMUXCEFPostMessagePumpWork(delay_ms); + } + +private: + std::string load_extensions_; + std::string user_agent_product_; + + IMPLEMENT_REFCOUNTING(CMUXCEFApp); + DISALLOW_COPY_AND_ASSIGN(CMUXCEFApp); +}; + +} // namespace + +// MARK: - Engine bridge + +@implementation CMUXCEFEngineConfigBridge +@end + +@implementation CMUXCEFEngineBridge { + std::atomic _initialized; + CefRefPtr _app; +} + ++ (instancetype)shared { + static CMUXCEFEngineBridge *instance; + static dispatch_once_t once; + dispatch_once(&once, ^{ instance = [[CMUXCEFEngineBridge alloc] init]; }); + return instance; +} + ++ (int)executeSubprocessIfNeededWithArgc:(int)argc argv:(char **)argv { + // The browser process must not call CefExecuteProcess here. With the + // SwiftPM demo and cmux app bundle shape, doing so can wedge the later + // CefInitialize call before browser creation. Helpers still need this + // path and must bind libcef's stub table before entering CEF's loop. + BOOL isSubprocess = NO; + for (int i = 1; i < argc; ++i) { + if (argv[i] && strncmp(argv[i], "--type=", 7) == 0) { + isSubprocess = YES; + break; + } + } + if (!isSubprocess) { + return -1; + } + @autoreleasepool { + NSString *frameworkBinaryPath = + CMUXCEFResolveFrameworkBinaryPathFromArgv0(argc > 0 ? argv[0] : nullptr); + NSError *loadError = nil; + if (!CMUXCEFEnsureFrameworkLoaded(frameworkBinaryPath, &loadError)) { + fprintf(stderr, "CMUXCEF subprocess failed to load CEF framework: %s\n", + loadError.localizedDescription.UTF8String); + return 1; + } + CefMainArgs main_args(argc, argv); + CefRefPtr app(new CMUXCEFApp(nil, nil)); + int code = CefExecuteProcess(main_args, app, nullptr); + if (code >= 0) { + return code; + } + return -1; + } +} + +- (BOOL)isInitialized { return _initialized.load(); } + +- (BOOL)initializeWithConfig:(CMUXCEFEngineConfigBridge *)config + error:(NSError **)error { + if (_initialized.load()) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain + code:CMUXCEFInitErrorAlreadyInitialized + userInfo:@{NSLocalizedDescriptionKey: @"CEF already initialized"}]; + } + return NO; + } + if (config.rootCachePath.length == 0) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain + code:CMUXCEFInitErrorMissingRootCachePath + userInfo:@{NSLocalizedDescriptionKey: @"rootCachePath is required"}]; + } + return NO; + } + + [[NSFileManager defaultManager] createDirectoryAtPath:config.rootCachePath + withIntermediateDirectories:YES + attributes:nil + error:nil]; + + // CEF on macOS is shipped as a stub library: the libcef_dll_wrapper + // static archive contains a per-symbol stub that branches through a + // function-pointer table. That table is empty until cef_load_library() + // dlopen's the real framework and resolves each export. Without this + // call, every CefString operation jumps to NULL. + if (config.frameworkDirectoryPath.length > 0) { + setenv([kCMUXCEFFrameworksDirEnv UTF8String], + [config.frameworkDirectoryPath fileSystemRepresentation], + 1); + NSString *fwBinary = CMUXCEFFrameworkBinaryPath(config.frameworkDirectoryPath); + if (!CMUXCEFEnsureFrameworkLoaded(fwBinary, error)) { + return NO; + } + } + + CefMainArgs main_args(0, nullptr); + CefSettings settings; + // Dev/ad-hoc-signed CEF helpers do not carry Chromium's sandbox + // signing profile. This must be set at CefSettings level; appending a + // late --no-sandbox switch is not enough for early services created + // during CefInitialize (notably network.mojom.NetworkService). + settings.no_sandbox = true; + // cmux is a SwiftUI / AppKit app — its main thread runs + // NSApplication's run loop, NOT `CefRunMessageLoop()`. Without an + // external pump, CEF's UI thread message queue (which carries every + // browser → helper Mojo invitation) is never drained, helpers wait + // 15s for a bootstrap message that never arrives, and they suicide + // via `content/child/child_thread_impl.cc:902`. With + // `external_message_pump = true` CEF stays inert until we call + // `CefDoMessageLoopWork()` from the main run loop. + settings.external_message_pump = true; + CefString(&settings.root_cache_path).FromString([config.rootCachePath UTF8String]); + if (config.userAgentProduct.length > 0) { + CefString(&settings.user_agent_product) + .FromString([config.userAgentProduct UTF8String]); + } + if (config.logSeverity > 0 && config.logSeverity <= 5) { + settings.log_severity = static_cast(config.logSeverity); + } + if (config.frameworkDirectoryPath.length > 0) { + NSString *fwDir = config.frameworkDirectoryPath; + NSString *fwBundle = [fwDir stringByAppendingPathComponent: + @"Chromium Embedded Framework.framework"]; + NSString *resources = [fwBundle stringByAppendingPathComponent: + @"Versions/A/Resources"]; + CefString(&settings.framework_dir_path).FromString([fwBundle UTF8String]); + CefString(&settings.resources_dir_path).FromString([resources UTF8String]); + CefString(&settings.locales_dir_path).FromString( + [[resources stringByAppendingPathComponent:@"locales"] UTF8String]); + } + if (config.browserSubprocessPath.length > 0) { + CefString(&settings.browser_subprocess_path) + .FromString([config.browserSubprocessPath UTF8String]); + } + + _app = new CMUXCEFApp(config.loadExtensionsArg, config.userAgentProduct); + if (!CefInitialize(main_args, settings, _app, nullptr)) { + _app = nullptr; + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain + code:CMUXCEFInitErrorCefInitializeFailed + userInfo:@{NSLocalizedDescriptionKey: @"CefInitialize failed"}]; + } + return NO; + } + + _initialized.store(true); + // Anchor the profile registry under the engine's root_cache_path so + // per-profile cache dirs satisfy CEF's "child of root_cache_path" check. + [CMUXCEFProfileRegistryBridge shared].profilesRoot = + [config.rootCachePath stringByAppendingPathComponent:@"profiles"]; + // Kick the external pump immediately so CEF gets one tick and a chance + // to install its `OnScheduleMessagePumpWork` callback for subsequent + // ticks. + CMUXCEFPostMessagePumpWork(0); + // Belt-and-braces fallback: also drive CefDoMessageLoopWork from a + // 30Hz NSTimer. `OnScheduleMessagePumpWork` is the optimal path, but + // SwiftUI re-renders or modal panels can starve the main queue and + // leave Mojo bootstrap messages undelivered — which is what wedged + // the second-and-later CEF browsers in cmux. The fallback timer + // guarantees forward progress even when CEF's own callback is late. + [NSTimer scheduledTimerWithTimeInterval:1.0 / 30.0 + target:self + selector:@selector(_cmuxCefPumpTick:) + userInfo:nil + repeats:YES]; + os_log(cmuxCEFLog(), "CMUXCEFEngine initialized; root_cache_path=%{public}@", + config.rootCachePath); + return YES; +} + +- (void)shutdown { + if (!_initialized.exchange(false)) return; + os_log(cmuxCEFLog(), "CMUXCEFEngine shutting down"); + CefShutdown(); + _app = nullptr; +} + +- (void)runMessageLoop { + if (!_initialized.load()) return; + CefRunMessageLoop(); +} + +- (void)quitMessageLoop { + if (!_initialized.load()) return; + CefQuitMessageLoop(); +} + +- (void)_cmuxCefPumpTick:(NSTimer *)timer { + if (!_initialized.load()) { + [timer invalidate]; + return; + } + CMUXCEFRunMessagePumpWorkIfIdle(); +} + +@end + +// MARK: - Profile registry + +@interface CMUXCEFProfileBridge () +- (CefRefPtr)underlyingContext; +@end + +@implementation CMUXCEFProfileBridge { + CefRefPtr _context; +} + +- (instancetype)initWithName:(NSString *)name + cachePath:(NSString *)cachePath + context:(CefRefPtr)context { + if ((self = [super init])) { + _name = [name copy]; + _cachePath = [cachePath copy]; + _context = context; + } + return self; +} + +- (CefRefPtr)underlyingContext { return _context; } + +@end + + +@implementation CMUXCEFProfileRegistryBridge { + NSMutableDictionary *_byName; + std::mutex _mutex; +} + ++ (instancetype)shared { + static CMUXCEFProfileRegistryBridge *instance; + static dispatch_once_t once; + dispatch_once(&once, ^{ instance = [[CMUXCEFProfileRegistryBridge alloc] init]; }); + return instance; +} + +- (instancetype)init { + if ((self = [super init])) { + _byName = [NSMutableDictionary dictionary]; + } + return self; +} + +- (CMUXCEFProfileBridge *)profileForName:(NSString *)name { + NSCAssert(name.length > 0, @"profile name must be non-empty"); + std::lock_guard lock(_mutex); + + if (CMUXCEFProfileBridge *existing = _byName[name]) { + return existing; + } + + NSString *root = [self resolveProfilesRoot]; + NSString *safe = [self pathSafeName:name]; + NSString *cachePath = [root stringByAppendingPathComponent:safe]; + + // Chrome runtime (CEF 146) rejects every CreateContext call that + // supplies a cache_path under the engine's user_data_dir — see + // `cef/libcef/browser/chrome/chrome_browser_context.cc:116` + // ("Cannot create profile at path ...") — and falls back to a + // half-initialised context. Browsers attached to that context never + // ship compositor frames, so the pane stays blank. + // + // Until a Chrome-runtime-compatible profile factory is wired up + // (it requires going through `g_browser_process->profile_manager()` + // rather than CefRequestContext::CreateContext), every profile name + // shares the global context. cmux loses per-profile cookie / chrome. + // storage / extension isolation in this state — tracked in the + // PR description. + CefRefPtr ctx = CefRequestContext::GetGlobalContext(); + cachePath = [root stringByAppendingPathComponent:@"Default"]; + + CMUXCEFProfileBridge *bridge = + [[CMUXCEFProfileBridge alloc] initWithName:name + cachePath:cachePath + context:ctx]; + _byName[name] = bridge; + return bridge; +} + +- (void)destroyProfileForName:(NSString *)name { + std::lock_guard lock(_mutex); + CMUXCEFProfileBridge *bridge = _byName[name]; + if (!bridge) return; + [_byName removeObjectForKey:name]; + // CefRequestContext destruction is async; we wait for the CEF UI thread + // to drop it before nuking the cache dir. + NSString *cachePath = [bridge.cachePath copy]; + BOOL usesSharedDefaultCache = [[cachePath lastPathComponent] isEqualToString:@"Default"]; + if ([name hasPrefix:@"isolated-"] && !usesSharedDefaultCache) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [[NSFileManager defaultManager] removeItemAtPath:cachePath error:nil]; + }); + } +} + +- (NSString *)resolveProfilesRoot { + NSString *root = self.profilesRoot; + if (root.length > 0) return root; + // Fallback when the engine hasn't been started yet (unit tests, etc.). + NSString *appSup = [NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, + NSUserDomainMask, YES) firstObject]; + return [[appSup stringByAppendingPathComponent:@"cmux"] + stringByAppendingPathComponent:@"CEFRoot/profiles"]; +} + +- (NSString *)pathSafeName:(NSString *)name { + NSCharacterSet *unsafe = [[NSCharacterSet alphanumericCharacterSet] invertedSet]; + NSArray *parts = [name componentsSeparatedByCharactersInSet:unsafe]; + return [parts componentsJoinedByString:@"_"]; +} + +@end + +// MARK: - Browser bridge + +@interface CMUXCEFBrowserBridge () +@property (nonatomic, copy, nullable) NSString *currentTitle; +@property (nonatomic, copy, nullable) NSURL *currentURL; +@property (nonatomic, assign) BOOL isLoading; +@property (nonatomic, assign) BOOL canGoBack; +@property (nonatomic, assign) BOOL canGoForward; +@property (nonatomic, strong, nullable, readwrite) NSWindow *hostingWindow; +@property (nonatomic, strong, nullable, readwrite) NSView *embeddableView; +- (instancetype)initInternal NS_DESIGNATED_INITIALIZER; +- (void)attachBrowserView:(CefRefPtr)view + window:(CefRefPtr)window + hostingWindow:(NSWindow *)hostingWindow; +- (void)attachBrowser:(CefRefPtr)browser + hostingWindow:(NSWindow *)hostingWindow; +- (void)attachEmbeddableBrowser:(CefRefPtr)browser + browserView:(CefRefPtr)browserView + cefWindow:(CefRefPtr)cefWindow + view:(NSView *)view; +- (void)attachAlloyBrowserParentView:(NSView *)parentView; +- (void)setBrowser:(CefRefPtr)browser; +- (void)teardown; +@end + +namespace { + +// Forward Chromium callbacks back into the Objective-C bridge instance on +// the main thread. Held as a __weak reference inside the C++ object so the +// callback chain never extends the ObjC bridge's lifetime. +class CMUXCEFClient final : public CefClient, + public CefDisplayHandler, + public CefLoadHandler, + public CefLifeSpanHandler { +public: + explicit CMUXCEFClient(__weak CMUXCEFBrowserBridge *bridge) + : bridge_(bridge) {} + + // CefClient + CefRefPtr GetDisplayHandler() override { return this; } + CefRefPtr GetLoadHandler() override { return this; } + CefRefPtr GetLifeSpanHandler() override { return this; } + + // CefDisplayHandler + void OnTitleChange(CefRefPtr, const CefString& title) override { + NSString *t = [NSString stringWithUTF8String:title.ToString().c_str()]; + __weak __typeof(bridge_) weak = bridge_; + dispatch_async(dispatch_get_main_queue(), ^{ + __strong __typeof(weak) strong = weak; + if (!strong) return; + strong.currentTitle = t; + id delegate = strong.delegate; + if ([delegate respondsToSelector:@selector(browserBridge:didChangeTitle:)]) { + [delegate browserBridge:strong didChangeTitle:t]; + } + }); + } + + void OnAddressChange(CefRefPtr, CefRefPtr frame, + const CefString& url) override { + if (!frame->IsMain()) return; + NSString *s = [NSString stringWithUTF8String:url.ToString().c_str()]; + NSURL *u = [NSURL URLWithString:s]; + if (!u) return; + __weak __typeof(bridge_) weak = bridge_; + dispatch_async(dispatch_get_main_queue(), ^{ + __strong __typeof(weak) strong = weak; + if (!strong) return; + strong.currentURL = u; + id delegate = strong.delegate; + if ([delegate respondsToSelector:@selector(browserBridge:didChangeURL:)]) { + [delegate browserBridge:strong didChangeURL:u]; + } + }); + } + + // CefLoadHandler + void OnLoadingStateChange(CefRefPtr, bool isLoading, + bool canGoBack, bool canGoForward) override { + __weak __typeof(bridge_) weak = bridge_; + dispatch_async(dispatch_get_main_queue(), ^{ + __strong __typeof(weak) strong = weak; + if (!strong) return; + BOOL wasLoading = strong.isLoading; + strong.isLoading = isLoading; + strong.canGoBack = canGoBack; + strong.canGoForward = canGoForward; + id delegate = strong.delegate; + if (isLoading && !wasLoading && + [delegate respondsToSelector:@selector(browserBridgeDidStartLoading:)]) { + [delegate browserBridgeDidStartLoading:strong]; + } else if (!isLoading && wasLoading && + [delegate respondsToSelector:@selector(browserBridgeDidFinishLoading:)]) { + [delegate browserBridgeDidFinishLoading:strong]; + } + }); + } + + void OnLoadError(CefRefPtr, CefRefPtr frame, + ErrorCode errorCode, const CefString& errorText, + const CefString& failedUrl) override { + if (!frame->IsMain()) return; + // ERR_ABORTED is fired during normal navigation cancellation; not an error. + if (errorCode == ERR_ABORTED) return; + NSString *msg = [NSString stringWithFormat:@"CEF load error %d (%s) for %s", + static_cast(errorCode), + errorText.ToString().c_str(), + failedUrl.ToString().c_str()]; + NSError *err = [NSError errorWithDomain:@"CMUXCEF.load" + code:static_cast(errorCode) + userInfo:@{NSLocalizedDescriptionKey: msg}]; + __weak __typeof(bridge_) weak = bridge_; + dispatch_async(dispatch_get_main_queue(), ^{ + __strong __typeof(weak) strong = weak; + if (!strong) return; + id delegate = strong.delegate; + if ([delegate respondsToSelector:@selector(browserBridge:didFailLoad:)]) { + [delegate browserBridge:strong didFailLoad:err]; + } + }); + } + + // CefLifeSpanHandler — capture the CefBrowser ref as soon as it exists. + void OnAfterCreated(CefRefPtr browser) override { + __weak __typeof(bridge_) weak = bridge_; + CefRefPtr ref = browser; + dispatch_async(dispatch_get_main_queue(), ^{ + __strong __typeof(weak) strong = weak; + if (!strong) return; + [strong setBrowser:ref]; + }); + } + +private: + __weak CMUXCEFBrowserBridge *bridge_; + IMPLEMENT_REFCOUNTING(CMUXCEFClient); + DISALLOW_COPY_AND_ASSIGN(CMUXCEFClient); +}; + +class CMUXCEFBrowserViewDelegate final : public CefBrowserViewDelegate { +public: + CMUXCEFBrowserViewDelegate() = default; + + cef_runtime_style_t GetBrowserRuntimeStyle() override { + return CEF_RUNTIME_STYLE_CHROME; + } + + // cmux owns the browser chrome in SwiftUI. Leaving CEF's Chrome toolbar + // disabled keeps Path B as a reparentable page-content view instead of + // asking Chrome runtime to create its own visible browser window. + ChromeToolbarType GetChromeToolbarType( + CefRefPtr browser_view) override { + return CEF_CTT_NONE; + } +private: + IMPLEMENT_REFCOUNTING(CMUXCEFBrowserViewDelegate); + DISALLOW_COPY_AND_ASSIGN(CMUXCEFBrowserViewDelegate); +}; + +static void CMUXCEFParkCEFWindow(NSWindow *window); + +class CMUXCEFWindowDelegate final : public CefWindowDelegate { +public: + explicit CMUXCEFWindowDelegate(CefRefPtr browser_view) + : browser_view_(browser_view) {} + + cef_runtime_style_t GetWindowRuntimeStyle() override { + return CEF_RUNTIME_STYLE_CHROME; + } + + void OnWindowCreated(CefRefPtr window) override { + window->SetBounds(CefRect(-30000, -30000, 4000, 4000)); + window->AddChildView(browser_view_); + // Trigger Show so CEF Chrome runtime spins up the browser process + // tree. The Swift caller then orderOut + addChildWindow as needed. + window->Show(); + window->SetBounds(CefRect(-30000, -30000, 4000, 4000)); + CefWindowHandle handle = window->GetWindowHandle(); + if (handle) { + id obj = (__bridge id)handle; + if ([obj isKindOfClass:[NSWindow class]]) { + CMUXCEFParkCEFWindow((NSWindow *)obj); + } else if ([obj isKindOfClass:[NSView class]]) { + CMUXCEFParkCEFWindow([(NSView *)obj window]); + } + } + } + + CefRect GetInitialBounds(CefRefPtr) override { + return CefRect(-30000, -30000, 4000, 4000); + } + + // cmux owns the browser chrome; the CEF NSWindow is overlaid as a + // childWindow on the cmux pane. Tell CEF to create the NSWindow + // frameless at construction time (not by mutating styleMask after, + // which destabilises Chromium's compositor) and without standard + // window buttons (no traffic lights, no titlebar). + bool IsFrameless(CefRefPtr) override { return true; } + bool WithStandardWindowButtons(CefRefPtr) override { return false; } + bool CanMinimize(CefRefPtr) override { return false; } + bool CanMaximize(CefRefPtr) override { return false; } + bool CanResize(CefRefPtr) override { return true; } + bool CanClose(CefRefPtr) override { return true; } + +private: + CefRefPtr browser_view_; + IMPLEMENT_REFCOUNTING(CMUXCEFWindowDelegate); + DISALLOW_COPY_AND_ASSIGN(CMUXCEFWindowDelegate); +}; + +static void CMUXCEFParkCEFWindow(NSWindow *window) { + if (!window) return; + [window setFrame:NSMakeRect(-30000, -30000, 4000, 4000) display:NO]; + [window setAlphaValue:0.0]; + [window setIgnoresMouseEvents:YES]; + [window setHasShadow:NO]; + window.collectionBehavior = + NSWindowCollectionBehaviorFullScreenAuxiliary + | NSWindowCollectionBehaviorMoveToActiveSpace + | NSWindowCollectionBehaviorIgnoresCycle + | NSWindowCollectionBehaviorStationary; + [window orderFront:nil]; +} + +} // namespace + +@implementation CMUXCEFBrowserBridge { + CefRefPtr _browserView; + CefRefPtr _cefWindow; + CefRefPtr _client; + CefRefPtr _browser; + // Strong pointer to the NSWindow Chromium owns (the "CefWindow" on + // the macOS side). Path B reparents `_embeddableView` into the cmux + // hierarchy, so `_embeddableView.window` switches to cmux's main + // window — we lose any way back to the CEF NSWindow without + // stashing it here. + NSWindow *_cefPlatformWindow; + // The cmux host NSWindow this browser has been attached to via + // `addChildWindow`. Set lazily by `attachToHostWindow:`. + NSWindow *_hostNSWindow; +} +@synthesize hostingWindow = _hostingWindow; +@synthesize embeddableView = _embeddableView; + +- (instancetype)init { + return [self initInternal]; +} + +- (instancetype)initInternal { + if ((self = [super init])) { + _isLoading = NO; + _canGoBack = NO; + _canGoForward = NO; + } + return self; +} + +- (void)attachBrowserView:(CefRefPtr)view + window:(CefRefPtr)window + hostingWindow:(NSWindow *)hostingWindow { + _browserView = view; + _cefWindow = window; + _hostingWindow = hostingWindow; +} + +- (void)attachBrowser:(CefRefPtr)browser + hostingWindow:(NSWindow *)hostingWindow { + _browser = browser; + _hostingWindow = hostingWindow; +} + +- (void)attachEmbeddableBrowser:(CefRefPtr)browser + browserView:(CefRefPtr)browserView + cefWindow:(CefRefPtr)cefWindow + view:(NSView *)view { + _browser = browser; + _browserView = browserView; + _cefWindow = cefWindow; + _embeddableView = view; + _cefPlatformWindow = view.window; +} + +- (void)attachAlloyBrowserParentView:(NSView *)parentView { + // Alloy runtime path: the browser's NSView is added by CEF as a + // subview of `parentView`. We don't expose an embeddableView here — + // the cmux pane already owns the parentView and the CEF subview + // lives inside it. The CefBrowser pointer is filled in later by + // CMUXCEFClient::OnAfterCreated via -setBrowser:. + _embeddableView = parentView; // for resize/sync convenience + _cefPlatformWindow = parentView.window; +} + +- (void)setBrowser:(CefRefPtr)browser { + _browser = browser; +} + +- (void)loadURL:(NSURL *)url { + if (!_browser || !url) return; + CefString cefURL; + cefURL.FromString([[url absoluteString] UTF8String]); + _browser->GetMainFrame()->LoadURL(cefURL); +} + +- (void)goBack { + if (_browser && _browser->CanGoBack()) _browser->GoBack(); +} + +- (void)goForward { + if (_browser && _browser->CanGoForward()) _browser->GoForward(); +} + +- (void)reload { + if (_browser) _browser->Reload(); +} + +- (void)stopLoading { + if (_browser) _browser->StopLoad(); +} + +- (void)showDevTools { + if (!_browser) return; + CefWindowInfo windowInfo; + // Chrome runtime DevTools must be Views-hosted; with all-default + // windowInfo CEF picks a sensible top-level window. + CefBrowserSettings settings; + _browser->GetHost()->ShowDevTools(windowInfo, /*client*/ nullptr, + settings, /*inspectAt*/ CefPoint()); +} + +- (void)closeDevTools { + if (_browser) _browser->GetHost()->CloseDevTools(); +} + +- (void)close { + if (_browser) { + _browser->GetHost()->CloseBrowser(/*force_close*/ true); + } + [self teardown]; +} + +- (void)teardown { + _browser = nullptr; + _browserView = nullptr; + _cefWindow = nullptr; + _client = nullptr; + _hostingWindow = nil; + _cefPlatformWindow = nil; +} + +- (void)syncRenderFrameToScreenRect:(NSRect)screenRect { + // Path B reparent (matches `CMUXCEFDemoApp/main.swift`): the + // BridgedContentView has been reparented out of CEF's offscreen + // NSWindow into a cmux SwiftUI container. We deliberately do NOT + // call `_cefWindow->SetBounds` here — that triggers Chromium to + // resize its IOSurface to the new rect, clear it, and (because + // we have no `WasShown()` equivalent in Chrome runtime) never + // repaint. The result is a black pane. + // + // Instead we leave the CEF NSWindow at its initial offscreen + // 4000x4000 bounds. Chromium keeps the IOSurface at 4000x4000 + // and continues painting page content into it. The reparented + // BridgedContentView shows the top-left region of that IOSurface + // clipped to its (much smaller) bounds. This is the same setup + // the demo uses and is what gets pixels on screen today. + if (_cefPlatformWindow == nil) return; + + // Optional legacy childWindow path, only when an explicit host + // window has been attached via `attach(toHost:)`. Path B reparent + // does not need this — it stays a no-op. + if (_hostNSWindow != nil && _cefWindow) { + if (![_hostNSWindow.childWindows containsObject:_cefPlatformWindow]) { + [_hostNSWindow addChildWindow:_cefPlatformWindow ordered:NSWindowAbove]; + } + [_cefPlatformWindow setAlphaValue:1.0]; + [_cefPlatformWindow setHasShadow:NO]; + [_cefPlatformWindow setIgnoresMouseEvents:NO]; + for (NSInteger b = NSWindowCloseButton; b <= NSWindowZoomButton; ++b) { + [[_cefPlatformWindow standardWindowButton:(NSWindowButton)b] setHidden:YES]; + } + CGFloat screenHeight = [[[NSScreen screens] firstObject] frame].size.height; + int cefY = (int)(screenHeight - NSMaxY(screenRect)); + CefRect cefRect((int)NSMinX(screenRect), cefY, + (int)NSWidth(screenRect), (int)NSHeight(screenRect)); + _cefWindow->SetBounds(cefRect); + } +} + +- (void)attachToHostWindow:(NSWindow *)hostWindow { + _hostNSWindow = hostWindow; +} + +- (void)notifyEmbedHostResizedAndShown { + if (!_browser) return; + CefRefPtr host = _browser->GetHost(); + if (!host) return; + host->WasHidden(false); + host->WasResized(); + host->Invalidate(PET_VIEW); + host->SetFocus(true); +} + +- (void)setClient:(CefRefPtr)client { _client = client; } + +@end + + +@implementation CMUXCEFEngineBridge (BrowserCreation) + +- (CMUXCEFBrowserBridge *)createBrowserInProfile:(CMUXCEFProfileBridge *)profile + initialURL:(NSURL *)initialURL + error:(NSError **)error { + NSCAssert([NSThread isMainThread], + @"createBrowserInProfile must be called on the main thread"); + if (!self.isInitialized) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain code:-1 + userInfo:@{NSLocalizedDescriptionKey: + @"CEF is not initialized"}]; + } + return nil; + } + if (!profile) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain code:-2 + userInfo:@{NSLocalizedDescriptionKey: + @"profile is nil"}]; + } + return nil; + } + + CMUXCEFBrowserBridge *bridge = [[CMUXCEFBrowserBridge alloc] initInternal]; + CefRefPtr client(new CMUXCEFClient(bridge)); + [bridge setClient:client]; + + // Use CefBrowserHost::CreateBrowserSync with no parent_view and an + // explicit CEF_RUNTIME_STYLE_CHROME. CEF then constructs a fully- + // chromed native browser window (tab strip, URL bar, back/forward, + // extension toolbar, menu, NTP) — the same window structure the user + // would see in a stock Chromium build. The caller can still hide + // the OS window via the returned NSWindow + child-window tracking + // when cmux is ready to dock it inside a pane. + CefWindowInfo windowInfo; + windowInfo.runtime_style = CEF_RUNTIME_STYLE_CHROME; + windowInfo.bounds = CefRect(100, 100, 1024, 720); + + CefBrowserSettings browserSettings; + CefString url; + url.FromString([[initialURL absoluteString] UTF8String]); + + CefRefPtr browser = CefBrowserHost::CreateBrowserSync( + windowInfo, client.get(), url, browserSettings, + /*extra_info*/ nullptr, [profile underlyingContext]); + if (!browser) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain code:-3 + userInfo:@{NSLocalizedDescriptionKey: + @"CefBrowserHost::CreateBrowserSync returned null"}]; + } + return nil; + } + + // On macOS, GetWindowHandle returns the NSView backing the browser + // content; its window is the Chrome runtime native NSWindow. + CefWindowHandle handle = browser->GetHost()->GetWindowHandle(); + NSView *contentView = (__bridge NSView *)handle; + NSWindow *hostingWindow = contentView.window; + + [bridge attachBrowser:browser hostingWindow:hostingWindow]; + return bridge; +} + +- (CMUXCEFBrowserBridge *)createEmbeddableBrowserInProfile:(CMUXCEFProfileBridge *)profile + initialURL:(NSURL *)initialURL + error:(NSError **)error { + NSCAssert([NSThread isMainThread], + @"createEmbeddableBrowserInProfile must be called on the main thread"); + if (!self.isInitialized) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain code:-1 + userInfo:@{NSLocalizedDescriptionKey: + @"CEF is not initialized"}]; + } + return nil; + } + if (!profile) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain code:-2 + userInfo:@{NSLocalizedDescriptionKey: + @"profile is nil"}]; + } + return nil; + } + + CMUXCEFBrowserBridge *bridge = [[CMUXCEFBrowserBridge alloc] initInternal]; + CefRefPtr client(new CMUXCEFClient(bridge)); + [bridge setClient:client]; + + // Build a CefBrowserView via the Views framework, request Chrome + // runtime style so extension/popup features stay available, then + // wrap it in a hidden CefWindow so CEF actually instantiates the + // backing NSView. After that we lift the NSView out of CefWindow's + // hierarchy and hand it to the caller for cmux pane embedding. + CefBrowserSettings browserSettings; + CefString url; + url.FromString([[initialURL absoluteString] UTF8String]); + + CefRefPtr browserView = CefBrowserView::CreateBrowserView( + client.get(), + url, + browserSettings, + /*extra_info*/ nullptr, + [profile underlyingContext], + new CMUXCEFBrowserViewDelegate()); + if (!browserView) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain code:-3 + userInfo:@{NSLocalizedDescriptionKey: + @"CefBrowserView::CreateBrowserView returned null"}]; + } + return nil; + } + + CefRefPtr cefWindow = CefWindow::CreateTopLevelWindow( + new CMUXCEFWindowDelegate(browserView)); + if (!cefWindow) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain code:-4 + userInfo:@{NSLocalizedDescriptionKey: + @"CefWindow::CreateTopLevelWindow returned null"}]; + } + return nil; + } + + // The CefBrowser becomes available the moment the BrowserView is + // added to a CefWindow (which happened inside CMUXCEFWindowDelegate:: + // OnWindowCreated). Pull it out so we can ask CefBrowserHost for the + // ACTUAL render NSView (CefWindow::GetWindowHandle gives the whole + // Views container, not the browser content). + CefRefPtr browser = browserView->GetBrowser(); + if (!browser) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain code:-5 + userInfo:@{NSLocalizedDescriptionKey: + @"CefBrowserView produced no CefBrowser"}]; + } + return nil; + } + CefWindowHandle handle = browser->GetHost()->GetWindowHandle(); + NSView *innerView = (__bridge NSView *)handle; + NSWindow *cefNSWindow = innerView.window; + if (!innerView || !cefNSWindow) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain code:-6 + userInfo:@{NSLocalizedDescriptionKey: + @"CefBrowserHost has no NSView yet"}]; + } + return nil; + } + + // CEF only ticks its renderer while the CefWindow's NSWindow is + // ordered on-screen, AND clips the render canvas to the CefWindow + // frame size. Park it far offscreen at a generous 4000×4000 so the + // canvas is big enough for any cmux pane. alpha=0 + off-screen + + // ignoresMouseEvents keeps it from being visible to the user or + // intercepting events. + CMUXCEFParkCEFWindow(cefNSWindow); + + // In Chrome runtime the CefWindow host and the browser host can report + // different Cocoa handles. Park both when present; otherwise CEF may + // leave behind a visible empty native window while the content view is + // reparented into cmux. + id cefWindowHandle = (__bridge id)cefWindow->GetWindowHandle(); + NSWindow *cefTopLevelWindow = nil; + if ([cefWindowHandle isKindOfClass:[NSWindow class]]) { + cefTopLevelWindow = (NSWindow *)cefWindowHandle; + } else if ([cefWindowHandle isKindOfClass:[NSView class]]) { + cefTopLevelWindow = [(NSView *)cefWindowHandle window]; + } + if (cefTopLevelWindow && cefTopLevelWindow != cefNSWindow) { + CMUXCEFParkCEFWindow(cefTopLevelWindow); + } + + [bridge attachEmbeddableBrowser:browser + browserView:browserView + cefWindow:cefWindow + view:innerView]; + return bridge; +} + +#pragma mark - Path C: Alloy runtime + parent_view native embedding + +- (CMUXCEFBrowserBridge *)createAlloyBrowserWithParentView:(NSView *)parentView + bounds:(NSRect)bounds + profile:(CMUXCEFProfileBridge *)profile + initialURL:(NSURL *)initialURL + error:(NSError **)error { + NSCAssert([NSThread isMainThread], + @"createAlloyBrowser must be called on the main thread"); + if (!self.isInitialized) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain code:-1 + userInfo:@{NSLocalizedDescriptionKey: + @"CEF is not initialized"}]; + } + return nil; + } + if (!profile) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain code:-2 + userInfo:@{NSLocalizedDescriptionKey: + @"profile is nil"}]; + } + return nil; + } + if (!parentView) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain code:-3 + userInfo:@{NSLocalizedDescriptionKey: + @"parentView is nil"}]; + } + return nil; + } + + CMUXCEFBrowserBridge *bridge = [[CMUXCEFBrowserBridge alloc] initInternal]; + CefRefPtr client(new CMUXCEFClient(bridge)); + [bridge setClient:client]; + + CefWindowInfo windowInfo; + // SetAsChild forces Alloy runtime (per cef_types_mac.h §runtime_style: + // "Alloy style will always be used … if |parent_view| is provided."). + // The CEF browser becomes a regular NSView subview of `parentView`. + windowInfo.SetAsChild( + (__bridge cef_window_handle_t)parentView, + CefRect((int)NSMinX(bounds), (int)NSMinY(bounds), + (int)NSWidth(bounds), (int)NSHeight(bounds))); + windowInfo.runtime_style = CEF_RUNTIME_STYLE_ALLOY; + + CefBrowserSettings browserSettings; + CefString url; + url.FromString([[initialURL absoluteString] UTF8String]); + + bool ok = CefBrowserHost::CreateBrowser( + windowInfo, + client.get(), + url, + browserSettings, + /*extra_info*/ nullptr, + [profile underlyingContext]); + if (!ok) { + if (error) { + *error = [NSError errorWithDomain:kCMUXCEFErrorDomain code:-4 + userInfo:@{NSLocalizedDescriptionKey: + @"CefBrowserHost::CreateBrowser returned false"}]; + } + return nil; + } + + // CreateBrowser is async — the CefBrowser may not be available yet. + // The client's OnAfterCreated wires up the browser pointer back into + // the bridge. We return the bridge immediately; cmux can ignore the + // window-handle accessors until OnAfterCreated fires. + [bridge attachAlloyBrowserParentView:parentView]; + return bridge; +} + +@end diff --git a/CEF/Sources/CMUXCEFBridge/include/CMUXCEFBridge.h b/CEF/Sources/CMUXCEFBridge/include/CMUXCEFBridge.h new file mode 100644 index 000000000000..e5722144f7c0 --- /dev/null +++ b/CEF/Sources/CMUXCEFBridge/include/CMUXCEFBridge.h @@ -0,0 +1,288 @@ +// CMUXCEFBridge.h — public ObjC-visible bridge between Swift (CMUXCEF) +// and the C++ CEF library. **All** CEF C++ interaction lives behind +// this wall; Swift never touches CefRefPtr, CefBrowser, or any other +// CEF type directly. +// +// Threading: every method declared here is documented for either +// "MainActor only" (UI / lifecycle) or "any thread" (callback registration). +// The Swift side enforces MainActor isolation; the ObjC++ side enforces +// CEF_REQUIRE_UI_THREAD() at the entry points that need it. +// +// Ownership: any object returned with a leading "create" or "new" in its +// name is owned by Swift via a strong reference; ARC keeps it alive. The +// underlying CEF reference is balanced inside the bridge. + +#pragma once + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@class CMUXCEFBrowserBridge; +@class CMUXCEFProfileBridge; + +// MARK: - Engine lifecycle + +/// Configuration for `CMUXCEFEngineBridge initializeWithConfig:`. Mirrors +/// the relevant subset of CefSettings + cmux's curated extension list. +@interface CMUXCEFEngineConfigBridge : NSObject + +/// Absolute path to the root cache dir. Must exist before init. +/// Maps to CefSettings.root_cache_path. +@property (nonatomic, copy) NSString *rootCachePath; + +/// Comma-joined absolute paths to unpacked extension directories. Passed +/// to Chromium's command line as --load-extension. Maps onto Chrome's +/// runtime extension subsystem; same extensions are visible to every +/// CefRequestContext in the process. Per-profile *state* (storage, +/// cookies, login) stays isolated. +@property (nonatomic, copy, nullable) NSString *loadExtensionsArg; + +/// If non-zero, override CEF's log severity. +/// 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 5 = fatal. +@property (nonatomic, assign) NSInteger logSeverity; + +/// Optional. Forwarded to CefSettings.user_agent_product. cmux brand +/// suffix appears in chrome://version. +@property (nonatomic, copy, nullable) NSString *userAgentProduct; + +/// Optional. Absolute path to the directory containing the +/// `Chromium Embedded Framework.framework` and helper .app bundles. +/// When cmux is running inside a proper .app bundle this is left nil +/// and CEF discovers everything via bundle layout. When running outside +/// a bundle (CLI / SwiftPM `swift run`), the caller must set this and +/// the helper paths so CEF can spawn subprocesses and locate +/// `icudtl.dat`. +@property (nonatomic, copy, nullable) NSString *frameworkDirectoryPath; + +/// Optional. Absolute path to the helper executable used for GPU / +/// utility / renderer subprocesses. When nil, CEF discovers it via the +/// .app layout convention. +@property (nonatomic, copy, nullable) NSString *browserSubprocessPath; + +@end + + +/// Process-wide singleton that wraps `CefInitialize` / `CefShutdown` and +/// the CefApp subclass that forwards cmux command-line options. +/// +/// Lifecycle (called exactly once from the cmux app delegate): +/// 1. `+executeSubprocessIfNeeded:argv:` — first thing in main(). +/// If this returns non-negative, the process is a CEF helper; exit +/// with that code immediately. +/// 2. `[CMUXCEFEngineBridge shared] initializeWithConfig:]` — call +/// from applicationDidFinishLaunching: on the main thread. +/// 3. `[CMUXCEFEngineBridge shared] shutdown]` — call from +/// applicationWillTerminate:. +@interface CMUXCEFEngineBridge : NSObject + ++ (instancetype)shared; + +/// `argc`/`argv` are the values passed to main(). When this returns a +/// non-negative exit code, the current process is a CEF helper (renderer, +/// gpu, utility, etc.) and the caller must exit with that code without +/// touching anything else. When it returns -1, the current process is the +/// browser process and main() should continue normally. ++ (int)executeSubprocessIfNeededWithArgc:(int)argc argv:(char *_Nullable *_Nullable)argv + NS_SWIFT_NAME(executeSubprocessIfNeeded(argc:argv:)); + +/// One-shot. Initializes CEF. Returns NO on failure; `error` is populated +/// with a domain "CMUXCEF" code from `CMUXCEFInitError` enum (in the .mm). +- (BOOL)initializeWithConfig:(CMUXCEFEngineConfigBridge *)config + error:(NSError **)error; + +/// Idempotent. Calls CefShutdown. After this, no further CEF interaction +/// is possible from this process. +- (void)shutdown; + +@property (nonatomic, readonly) BOOL isInitialized; + +/// Block on the CEF UI message loop. Returns when -quitMessageLoop is +/// called. Use this from a CLI / SwiftPM `swift run` integration test. +/// cmux apps that run AppKit's NSApp.run() should NOT call this; they +/// should instead let CEF integrate via CefDoMessageLoopWork (driven +/// from an NSRunLoop observer — to be exposed in a later phase). +- (void)runMessageLoop; +- (void)quitMessageLoop; + +@end + +// MARK: - Profile registry + +/// One per CEF `CefRequestContext`. Acquired by name; same name returns +/// the same bridge. Owns the underlying cache directory layout. +@interface CMUXCEFProfileBridge : NSObject + +/// Stable name (e.g. "default", "work", "isolated-"). Path-safe. +@property (nonatomic, readonly) NSString *name; + +/// Absolute path to this profile's cache directory. Lives under the +/// engine's root_cache_path. +@property (nonatomic, readonly) NSString *cachePath; + +@end + + +@interface CMUXCEFProfileRegistryBridge : NSObject + ++ (instancetype)shared; + +/// Engine sets this once during initialize. Profile cache directories +/// are created as `/` underneath it. Must satisfy +/// CEF's "cache_path is a child of root_cache_path" rule. +@property (nonatomic, copy, nullable) NSString *profilesRoot; + +/// Look up an existing profile bridge or create a new one with the given +/// name. The returned bridge is retained by the registry; callers may +/// hold a strong reference for as long as they need. +- (CMUXCEFProfileBridge *)profileForName:(NSString *)name; + +/// Garbage-collect a profile bridge whose backing browsers have all +/// been closed and whose directory is no longer needed. The cache +/// directory is removed from disk after the underlying RequestContext +/// is destroyed. +- (void)destroyProfileForName:(NSString *)name; + +@end + +// MARK: - Browser instance + +/// Delegate that receives navigation / title / loading-state callbacks +/// for a single CEF browser. Methods are invoked on the main thread. +@protocol CMUXCEFBrowserBridgeDelegate +@optional +- (void)browserBridgeDidStartLoading:(CMUXCEFBrowserBridge *)bridge; +- (void)browserBridgeDidFinishLoading:(CMUXCEFBrowserBridge *)bridge; +- (void)browserBridge:(CMUXCEFBrowserBridge *)bridge didChangeTitle:(NSString *)title; +- (void)browserBridge:(CMUXCEFBrowserBridge *)bridge didChangeURL:(NSURL *)url; +- (void)browserBridge:(CMUXCEFBrowserBridge *)bridge didFailLoad:(NSError *)error; +@end + + +/// A single CEF browser instance. Two embedding modes: +/// * `hostingWindow` — top-level NSWindow path used by +/// `-createBrowserInProfile:initialURL:`. cmux pane reparenting code +/// uses `addChildWindow:` to glue it to a main window. +/// * `embeddableView` — content NSView extracted from the underlying +/// CefBrowserView (Path B). The caller can `addSubview:` this +/// directly into a regular NSView hierarchy. +/// Exactly one of these is non-nil per bridge instance. +@interface CMUXCEFBrowserBridge : NSObject + +/// Non-nil when this bridge was created via +/// `-createBrowserInProfile:initialURL:`. +@property (nonatomic, readonly, nullable) NSWindow *hostingWindow; + +/// Non-nil when this bridge was created via +/// `-createEmbeddableBrowserInProfile:initialURL:`. The NSView has been +/// removed from CEF's own CefWindow content view; the caller adds it +/// into a cmux NSView hierarchy. +@property (nonatomic, readonly, nullable) NSView *embeddableView; + +/// For embeddable browsers: align the internal CefWindow's frame to the +/// embedding container's on-screen rect. CEF computes mouse coordinates +/// and clips its render canvas relative to the CefWindow frame, so the +/// CefWindow must occupy the same screen-space rectangle as the +/// embeddable NSView (even though it's invisible). Call this whenever +/// the embedding container's screen frame changes (resize, divider +/// drag, parent window move, screen change, …). +/// +/// No-op if this bridge has no embeddable view. +- (void)syncRenderFrameToScreenRect:(NSRect)screenRect; + +/// Tell the bridge which cmux NSWindow this browser should be hosted on +/// via `addChildWindow:`. The CEF NSWindow becomes a borderless child +/// window of this host; `syncRenderFrameToScreenRect:` then positions it +/// to match the cmux pane. +- (void)attachToHostWindow:(NSWindow *)hostWindow; + +/// Notify the underlying CefBrowserHost that the embedded NSView has been +/// reparented / resized so Chromium re-queries the view size and pushes a +/// fresh render layer. This is required when the cmux pane mounts the +/// extracted NSView in a SwiftUI hierarchy whose layout is asynchronous — +/// without this, Chromium can latch onto the transient 0×0 frame the view +/// has between `removeFromSuperview` and the first Auto-Layout pass. +- (void)notifyEmbedHostResizedAndShown; + +@property (nonatomic, weak, nullable) id delegate; + +/// Convenience accessors. May be nil before the browser has loaded +/// its first URL. +@property (nonatomic, readonly, nullable) NSString *currentTitle; +@property (nonatomic, readonly, nullable) NSURL *currentURL; +@property (nonatomic, readonly) BOOL isLoading; +@property (nonatomic, readonly) BOOL canGoBack; +@property (nonatomic, readonly) BOOL canGoForward; + +- (void)loadURL:(NSURL *)url; +- (void)goBack; +- (void)goForward; +- (void)reload; +- (void)stopLoading; + +/// Show the CEF DevTools window for this browser. First version uses a +/// new top-level window; docked DevTools comes later. +- (void)showDevTools; +- (void)closeDevTools; + +/// Tear down the underlying CEF browser. After this, the bridge is +/// inert. Must be called from the main thread. +- (void)close; + +@end + +/// Factory — only the engine knows how to wire CefRequestContext + +/// CefBrowserView + CefWindow together correctly. +@interface CMUXCEFEngineBridge (BrowserCreation) + +/// Create a new browser inside its own borderless top-level NSWindow, +/// using the given profile's RequestContext. The hosting window is +/// initially hidden; the caller adds it as a child window and calls +/// `orderFront:` / `setFrame:display:` to position and reveal it. +- (nullable CMUXCEFBrowserBridge *)createBrowserInProfile:(CMUXCEFProfileBridge *)profile + initialURL:(NSURL *)initialURL + error:(NSError **)error; + +/// Path B experiment — create a CEF browser inside an internal CefWindow, +/// then extract the inner NSView so it can be re-parented directly into +/// a cmux NSView hierarchy (NSSplitView, Bonsplit pane, etc.). +/// +/// On return, the bridge owns: +/// * a hidden CefWindow that keeps the browser alive +/// * a strong reference to the extracted NSView +/// +/// The returned NSView has already been removeFromSuperview'd; the caller +/// `addSubview:` into its own container. The browser continues to render +/// inside that NSView even though the NSView is no longer in CEF's own +/// CefWindow content-view tree. +/// +/// Returns nil + populates `error` on failure (CEF init not done, profile +/// nil, browser creation failed, NSView extraction failed). +- (nullable CMUXCEFBrowserBridge *)createEmbeddableBrowserInProfile:(CMUXCEFProfileBridge *)profile + initialURL:(NSURL *)initialURL + error:(NSError **)error; + +/// **Path C — Alloy runtime native NSView embedding.** +/// +/// Calls `CefBrowserHost::CreateBrowser` with `CefWindowInfo::SetAsChild(parentView, bounds)`. +/// In CEF 146 this forces Alloy runtime (Chrome runtime cannot embed into an +/// existing NSView — its compositor binds to a dedicated NSWindow). The CEF +/// browser becomes a regular NSView subview of `parentView` and behaves like +/// any AppKit view: layout via Auto Layout, follows split-divider drags, +/// participates in normal hit-testing, no addChildWindow / CARemoteLayer +/// gymnastics. +/// +/// Trade-off vs Chrome runtime: Alloy in CEF 146 does NOT expose the Chrome +/// extension subsystem. The browser still has full Chromium rendering, V8, +/// network stack, devtools, etc. Use this when extension support is not +/// required. +- (nullable CMUXCEFBrowserBridge *)createAlloyBrowserWithParentView:(NSView *)parentView + bounds:(NSRect)bounds + profile:(CMUXCEFProfileBridge *)profile + initialURL:(NSURL *)initialURL + error:(NSError **)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/CEF/Sources/CMUXCEFDemoApp/main.swift b/CEF/Sources/CMUXCEFDemoApp/main.swift new file mode 100644 index 000000000000..85ccff16dc33 --- /dev/null +++ b/CEF/Sources/CMUXCEFDemoApp/main.swift @@ -0,0 +1,497 @@ +// CMUXCEFDemoApp — Path B integration smoke test. +// +// Validates that the NSView produced by `CEFEngine.makeEmbeddableBrowser` +// behaves as a regular AppKit subview when planted inside an NSSplitView. +// This is the experiment for the cmux pane integration path: instead of +// a child NSWindow per pane (which conflicts with Bonsplit's NSSplitView +// divider hit-testing), we extract CEF's underlying NSView and let +// AppKit lay it out like any other pane. +// +// Run: +// cd CEF +// swift build --product CMUXCEFDemoApp +// ./Scripts/codesign_dev.sh +// .build/.../CMUXCEFDemoApp +// +// What to watch: +// * Three CEF browsers render content inside the NSSplitView. +// * Vertical dividers are draggable using native NSSplitView gestures. +// * Resizing the parent NSWindow flows through autolayout, no manual +// frame math required. + +import AppKit +import CMUXCEF + +// CEF Chrome runtime needs an NSApplication that responds to +// `isHandlingSendEvent` (CrAppProtocol). Promote our subclass to be the +// shared instance before any code touches NSApplication.shared. +final class CrApplication: NSApplication { + private var handlingSendEventStorage = false + @objc var isHandlingSendEvent: Bool { + get { handlingSendEventStorage } + set { handlingSendEventStorage = newValue } + } + @objc func setHandlingSendEvent(_ handlingSendEvent: Bool) { + handlingSendEventStorage = handlingSendEvent + } + override func sendEvent(_ event: NSEvent) { + let was = handlingSendEventStorage + handlingSendEventStorage = true + super.sendEvent(event) + handlingSendEventStorage = was + } +} + +_ = CrApplication.shared + +let exitCode = CEFEngine.executeSubprocessIfNeeded() +if exitCode >= 0 { + exit(exitCode) +} + +// MARK: - Demo controller + +@MainActor +final class DemoController: NSObject, NSSplitViewDelegate { + + private let panes: [(profile: String, url: URL)] = [ + ("default", URL(string: "https://github.com")!), + ("default", URL(string: "https://www.youtube.com")!), + ("default", URL(string: "https://example.com")!), + ] + + private var parentWindow: NSWindow! + private var splitView: NSSplitView! + private var browsers: [CEFBrowser] = [] + private var addressField: NSTextField! + private var pendingExtensionFolders: [URL] = [] + /// Index into `browsers` / `splitView.arrangedSubviews` of the pane + /// the user most recently clicked. Address-bar input + back / forward / + /// reload route to this pane. Defaults to right-most. + private var activePaneIndex: Int = 0 + private var paneContainers: [NSView] = [] + + func boot() { + // CEF init + let support: URL + do { + support = try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true) + } catch { + print("Failed to locate Application Support directory: \(error)") + exit(2) + } + let root = support + .appendingPathComponent("cmux-cef-demo", isDirectory: true) + .appendingPathComponent("CEFRoot", isDirectory: true) + + let execURL = URL(fileURLWithPath: CommandLine.arguments[0]) + .deletingLastPathComponent() + let frameworkDir = execURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("CEFArtifacts", isDirectory: true) + let helperPath = execURL.appendingPathComponent("CMUXCEFHelper") + + do { + try CEFEngine.shared.start(config: CEFEngineConfig( + rootCachePath: root, + extensionDirectories: pendingExtensionFolders, + logSeverity: 0, + userAgentProduct: "cmux-cef-demo/0", + frameworkDirectoryPath: frameworkDir, + browserSubprocessPath: helperPath)) + } catch { + print("CEFEngine.start failed: \(error)") + exit(2) + } + + // Parent window + parentWindow = NSWindow( + contentRect: NSRect(x: 80, y: 80, width: 1600, height: 900), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, defer: false) + parentWindow.title = + "cmux-cef-demo · Path B (NSSplitView + embedded CEF NSView)" + parentWindow.isReleasedWhenClosed = false + parentWindow.contentView?.wantsLayer = true + parentWindow.contentView?.layer?.backgroundColor = + NSColor(calibratedWhite: 0.08, alpha: 1).cgColor + + // Toolbar + let toolbar = makeToolbar() + toolbar.translatesAutoresizingMaskIntoConstraints = false + parentWindow.contentView!.addSubview(toolbar) + + // Split view — vanilla AppKit, native dividers. + splitView = NSSplitView() + splitView.isVertical = true + splitView.dividerStyle = .thin + splitView.delegate = self + splitView.translatesAutoresizingMaskIntoConstraints = false + parentWindow.contentView!.addSubview(splitView) + + NSLayoutConstraint.activate([ + toolbar.leadingAnchor.constraint(equalTo: parentWindow.contentView!.leadingAnchor), + toolbar.trailingAnchor.constraint(equalTo: parentWindow.contentView!.trailingAnchor), + toolbar.topAnchor.constraint(equalTo: parentWindow.contentView!.topAnchor), + toolbar.heightAnchor.constraint(equalToConstant: 36), + splitView.leadingAnchor.constraint(equalTo: parentWindow.contentView!.leadingAnchor), + splitView.trailingAnchor.constraint(equalTo: parentWindow.contentView!.trailingAnchor), + splitView.topAnchor.constraint(equalTo: toolbar.bottomAnchor), + splitView.bottomAnchor.constraint(equalTo: parentWindow.contentView!.bottomAnchor), + ]) + + parentWindow.makeKeyAndOrderFront(nil) + + // Seed three panes. + for pane in panes { + addPane(profile: pane.profile, url: pane.url) + } + } + + private func makeToolbar() -> NSView { + let view = NSView() + view.wantsLayer = true + view.layer?.backgroundColor = NSColor(calibratedWhite: 0.05, alpha: 1).cgColor + + let stack = NSStackView() + stack.orientation = .horizontal + stack.spacing = 6 + stack.edgeInsets = NSEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + stack.addArrangedSubview(makeBtn(symbol: "chevron.left", + tip: "Back", + action: #selector(backAction))) + stack.addArrangedSubview(makeBtn(symbol: "chevron.right", + tip: "Forward", + action: #selector(forwardAction))) + stack.addArrangedSubview(makeBtn(symbol: "arrow.clockwise", + tip: "Reload", + action: #selector(reloadAction))) + + // Address bar — applies to the right-most (newest) pane. + addressField = NSTextField() + addressField.placeholderString = "Enter URL and press ⏎" + addressField.stringValue = "https://github.com" + addressField.bezelStyle = .roundedBezel + addressField.focusRingType = .none + addressField.font = .systemFont(ofSize: 13) + addressField.target = self + addressField.action = #selector(addressSubmit) + addressField.translatesAutoresizingMaskIntoConstraints = false + addressField.setContentHuggingPriority(.defaultLow, for: .horizontal) + stack.addArrangedSubview(addressField) + addressField.widthAnchor.constraint(greaterThanOrEqualToConstant: 360).isActive = true + + stack.addArrangedSubview(makeBtn(symbol: "puzzlepiece.extension", + tip: "Manage extensions (chrome://extensions)", + action: #selector(openExtensionsAction))) + stack.addArrangedSubview(makeBtn(symbol: "folder.badge.plus", + tip: "Load unpacked extension folder…", + action: #selector(loadUnpackedExtensionAction))) + stack.addArrangedSubview(makeBtn(symbol: "rectangle.split.3x1", + tip: "Add pane (right)", + action: #selector(addPaneAction))) + stack.addArrangedSubview(makeBtn(symbol: "xmark.rectangle", + tip: "Close right-most pane", + action: #selector(removePaneAction))) + return view + } + + private func activeBrowser() -> CEFBrowser? { + guard !browsers.isEmpty else { return nil } + let idx = min(max(activePaneIndex, 0), browsers.count - 1) + return browsers[idx] + } + + fileprivate func setActivePane(_ container: NSView) { + if let idx = paneContainers.firstIndex(of: container) { + activePaneIndex = idx + refreshActivePaneHighlight() + if let url = browsers[idx].currentURL ?? URL(string: panes[idx % panes.count].url.absoluteString) { + addressField.stringValue = url.absoluteString + } + } + } + + private func refreshActivePaneHighlight() { + for (i, c) in paneContainers.enumerated() { + c.layer?.borderColor = (i == activePaneIndex) + ? NSColor.systemBlue.cgColor + : NSColor.clear.cgColor + c.layer?.borderWidth = (i == activePaneIndex) ? 2 : 0 + } + } + + @objc private func backAction() { activeBrowser()?.goBack() } + @objc private func forwardAction() { activeBrowser()?.goForward() } + @objc private func reloadAction() { activeBrowser()?.reload() } + + @objc private func addressSubmit() { + let raw = addressField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty else { return } + let url: URL? + if let direct = URL(string: raw), direct.scheme != nil { + url = direct + } else if raw.contains(".") && !raw.contains(" ") { + url = URL(string: "https://\(raw)") + } else { + var c = URLComponents() + c.scheme = "https"; c.host = "www.google.com"; c.path = "/search" + c.queryItems = [URLQueryItem(name: "q", value: raw)] + url = c.url + } + guard let target = url, let browser = activeBrowser() else { return } + addressField.stringValue = target.absoluteString + browser.load(target) + } + + @objc private func openExtensionsAction() { + guard let url = URL(string: "chrome://extensions") else { return } + addressField.stringValue = url.absoluteString + activeBrowser()?.load(url) + } + + @objc private func loadUnpackedExtensionAction() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.prompt = "Load Extension" + panel.message = + "Pick the unpacked extension folder (containing manifest.json). " + + "Note: CEF binds the extension list at engine start, so the " + + "selected path is appended and the message loop is restarted." + guard panel.runModal() == .OK, let folder = panel.url else { return } + pendingExtensionFolders.append(folder) + let alert = NSAlert() + alert.messageText = "Extension queued" + alert.informativeText = + "Will load the next time the demo starts:\n\(folder.path)\n\n" + + "Restart the demo, then open chrome://extensions to inspect loaded extensions." + alert.runModal() + if let url = URL(string: "chrome://extensions") { + activeBrowser()?.load(url) + } + } + + private func makeBtn(symbol: String, tip: String, action: Selector) -> NSButton { + let btn = NSButton() + btn.bezelStyle = .accessoryBarAction + btn.isBordered = false + btn.translatesAutoresizingMaskIntoConstraints = false + if let img = NSImage(systemSymbolName: symbol, accessibilityDescription: tip) { + btn.image = img + btn.imagePosition = .imageOnly + btn.contentTintColor = .white + } else { + btn.title = symbol + } + btn.toolTip = tip + btn.target = self + btn.action = action + btn.widthAnchor.constraint(equalToConstant: 32).isActive = true + btn.heightAnchor.constraint(equalToConstant: 28).isActive = true + return btn + } + + @objc private func addPaneAction() { + let next = panes[browsers.count % panes.count] + addPane(profile: next.profile, url: next.url) + } + + @objc private func removePaneAction() { + guard let last = browsers.popLast() else { return } + let paneView = last.embeddableView?.superview ?? paneContainers.last ?? splitView.arrangedSubviews.last + if let paneView { + splitView.removeArrangedSubview(paneView) + paneView.removeFromSuperview() + } + last.close() + if !paneContainers.isEmpty { paneContainers.removeLast() } + activePaneIndex = max(0, browsers.count - 1) + splitView.adjustSubviews() + refreshActivePaneHighlight() + } + + private func addPane(profile: String, url: URL) { + do { + let prof = CEFProfileRegistry.shared.profile(named: profile) + let browser = try CEFEngine.shared.makeEmbeddableBrowser( + profile: prof, initialURL: url) + guard let inner = browser.embeddableView else { + FileHandle.standardError.write( + "makeEmbeddableBrowser returned no NSView\n".data(using:.utf8)!) + return + } + + let container = PaneContainerView() + container.controller = self + container.browser = browser + container.wantsLayer = true + container.layer?.backgroundColor = + NSColor(calibratedWhite: 0.1, alpha: 1).cgColor + container.translatesAutoresizingMaskIntoConstraints = false + + inner.removeFromSuperview() + inner.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(inner) + NSLayoutConstraint.activate([ + inner.leadingAnchor.constraint(equalTo: container.leadingAnchor), + inner.trailingAnchor.constraint(equalTo: container.trailingAnchor), + inner.topAnchor.constraint(equalTo: container.topAnchor), + inner.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + splitView.addArrangedSubview(container) + paneContainers.append(container) + redistributePanesEqually() + browsers.append(browser) + activePaneIndex = browsers.count - 1 + refreshActivePaneHighlight() + + FileHandle.standardError.write( + "added pane: profile=\(profile) url=\(url)\n".data(using:.utf8)!) + } catch { + FileHandle.standardError.write( + "addPane failed: \(error)\n".data(using:.utf8)!) + } + } + + private func redistributePanesEqually() { + let n = splitView.arrangedSubviews.count + guard n > 1 else { return } + splitView.layoutSubtreeIfNeeded() + let totalWidth = splitView.bounds.width + let dividerThickness = splitView.dividerThickness + let usableWidth = totalWidth - dividerThickness * CGFloat(n - 1) + let paneWidth = usableWidth / CGFloat(n) + // Set divider positions left-to-right. + for i in 0 ..< (n - 1) { + let position = paneWidth * CGFloat(i + 1) + dividerThickness * CGFloat(i) + splitView.setPosition(position, ofDividerAt: i) + } + } + + // MARK: NSSplitViewDelegate + + func splitView(_ splitView: NSSplitView, + constrainMinCoordinate proposedMin: CGFloat, + ofSubviewAt dividerIndex: Int) -> CGFloat { + return proposedMin + 200 + } + + func splitView(_ splitView: NSSplitView, + constrainMaxCoordinate proposedMax: CGFloat, + ofSubviewAt dividerIndex: Int) -> CGFloat { + return proposedMax - 200 + } +} + +// MARK: - Pane container + +/// Container view for a single browser pane. Detects when the user +/// interacts with this pane (mouse-down event tap) and tells the +/// controller to make it the address bar's target. The CEF NSView is a +/// subview and still receives the click — we just observe it via the +/// event-monitor pattern (no event swallowing). +private final class PaneContainerView: NSView { + weak var controller: DemoController? + weak var browser: CEFBrowser? + private var clickMonitor: Any? + private var observers: [NSObjectProtocol] = [] + + override var isFlipped: Bool { false } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + for o in observers { NotificationCenter.default.removeObserver(o) } + observers.removeAll() + if let window = self.window { + clickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self, weak window] event in + guard let self, let window, event.window == window else { return event } + let pointInWindow = event.locationInWindow + let pointInSelf = self.convert(pointInWindow, from: nil) + if self.bounds.contains(pointInSelf) { + self.controller?.setActivePane(self) + } + return event + } + // Keep the CEF compositor's hit-test coordinate space in + // sync with the on-screen rect of this container. + postsFrameChangedNotifications = true + let frameObs = NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, object: self, queue: .main + ) { [weak self] _ in self?.syncCEFCoordinates() } + observers.append(frameObs) + let winMoveObs = NotificationCenter.default.addObserver( + forName: NSWindow.didMoveNotification, object: window, queue: .main + ) { [weak self] _ in self?.syncCEFCoordinates() } + observers.append(winMoveObs) + let winResizeObs = NotificationCenter.default.addObserver( + forName: NSWindow.didResizeNotification, object: window, queue: .main + ) { [weak self] _ in self?.syncCEFCoordinates() } + observers.append(winResizeObs) + syncCEFCoordinates() + } else if let m = clickMonitor { + NSEvent.removeMonitor(m) + clickMonitor = nil + } + } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + if newWindow == nil { + if let m = clickMonitor { NSEvent.removeMonitor(m); clickMonitor = nil } + for o in observers { NotificationCenter.default.removeObserver(o) } + observers.removeAll() + } + super.viewWillMove(toWindow: newWindow) + } + + private func syncCEFCoordinates() { + guard let browser = self.browser, let window = self.window else { return } + let inWin = convert(bounds, to: nil) + let onScreen = window.convertToScreen(inWin) + browser.syncRenderFrame(toScreen: onScreen) + } +} + +// MARK: - App boot + +let app = NSApplication.shared +app.setActivationPolicy(.regular) + +let controller = DemoController() +MainActor.assumeIsolated { controller.boot() } + +class AppDelegate: NSObject, NSApplicationDelegate { + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + func applicationWillTerminate(_ notification: Notification) {} +} +let delegate = AppDelegate() +app.delegate = delegate + +let appMenuItem = NSMenuItem() +appMenuItem.submenu = NSMenu() +appMenuItem.submenu?.addItem(NSMenuItem( + title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) +let menubar = NSMenu() +menubar.addItem(appMenuItem) +app.mainMenu = menubar + +MainActor.assumeIsolated { + CEFEngine.shared.runMessageLoop() + CEFEngine.shared.shutdown() +} diff --git a/CEF/Sources/CMUXCEFHelper/main.mm b/CEF/Sources/CMUXCEFHelper/main.mm new file mode 100644 index 000000000000..268361508b96 --- /dev/null +++ b/CEF/Sources/CMUXCEFHelper/main.mm @@ -0,0 +1,31 @@ +// CMUXCEFHelper main — entry point for the GPU / utility / network / +// alerts / plugin helper processes. CEF spawns one of these for each +// non-renderer subprocess role; the executable name is what shows up in +// Activity Monitor. +// +// The helper binary is compiled once and embedded into cmux.app as +// multiple .app bundles (WebView Helper.app, WebView Helper (GPU).app, +// WebView Helper (Plugin).app, WebView Helper (Alerts).app — all sharing +// the same Mach-O binary; the bundle name carries the role). +// +// This file intentionally has zero non-CEF dependencies. Any logic +// belongs in the browser process, which is the only place we can +// reason about cmux state. + +#import "../CMUXCEFBridge/include/CMUXCEFBridge.h" + +#include "include/cef_app.h" + +int main(int argc, char *argv[]) { + @autoreleasepool { + int code = [CMUXCEFEngineBridge executeSubprocessIfNeededWithArgc:argc argv:argv]; + if (code >= 0) { + return code; + } + // This binary is only run as a CEF helper. If we somehow land here + // (no --type= argument), exit with an error so the misuse is + // visible. + fprintf(stderr, "CMUXCEFHelper: refusing to run as the browser process\n"); + return 2; + } +} diff --git a/CEF/Sources/CMUXCEFHelperRenderer/main.mm b/CEF/Sources/CMUXCEFHelperRenderer/main.mm new file mode 100644 index 000000000000..42926f0bd888 --- /dev/null +++ b/CEF/Sources/CMUXCEFHelperRenderer/main.mm @@ -0,0 +1,25 @@ +// CMUXCEFHelperRenderer main — entry point for renderer subprocesses. +// One process per page (or per site, depending on Chromium's process +// isolation policy). cmux ships this as a separate executable because +// renderer helpers need their own entitlement profile (JIT + unsigned +// executable memory) distinct from the other utility helpers. +// +// Same shape as CMUXCEFHelper/main.mm — execute the CEF subprocess +// loop and exit. CEF's libcef takes care of CefRenderProcessHandler +// installation when the right hooks are registered on the CefApp +// passed to CefExecuteProcess. + +#import "../CMUXCEFBridge/include/CMUXCEFBridge.h" + +#include "include/cef_app.h" + +int main(int argc, char *argv[]) { + @autoreleasepool { + int code = [CMUXCEFEngineBridge executeSubprocessIfNeededWithArgc:argc argv:argv]; + if (code >= 0) { + return code; + } + fprintf(stderr, "CMUXCEFHelperRenderer: refusing to run as the browser process\n"); + return 2; + } +} diff --git a/CEF/Tests/CMUXCEFTests/CEFEngineTests.swift b/CEF/Tests/CMUXCEFTests/CEFEngineTests.swift new file mode 100644 index 000000000000..02d39a0b3ecc --- /dev/null +++ b/CEF/Tests/CMUXCEFTests/CEFEngineTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import CMUXCEF + +/// Behavioural tests for CMUXCEF. These tests exercise the bridge through +/// the public Swift API only; they do NOT grep source. See cmux test +/// quality policy in CLAUDE.md. +/// +/// The tests deliberately avoid starting CEF when running under CI without +/// a display server. `CEFEngine.start` opens IPC sockets and spawns helpers, +/// which is appropriate for an integration test but not a unit test. +/// The unit tests below verify configuration shape, profile naming, and +/// API contracts that hold *before* CEF has been initialized. +final class CEFEngineTests: XCTestCase { + + @MainActor + func testEngineIsNotRunningBeforeStart() { + // CEFEngine.shared is a singleton; multiple tests share it. + // We never call start() here. + XCTAssertFalse(CEFEngine.shared.isRunning, + "engine must not be running until start() is called") + XCTAssertNil(CEFEngine.shared.config, + "config must be nil before start()") + } + + @MainActor + func testConfigSerializesExtensionDirectoriesAsCommaJoinedPaths() { + let dirA = URL(fileURLWithPath: "/tmp/ext-a") + let dirB = URL(fileURLWithPath: "/tmp/ext-b") + let config = CEFEngineConfig( + rootCachePath: URL(fileURLWithPath: "/tmp/cmux-cef-test"), + extensionDirectories: [dirA, dirB]) + // The bridge expects a comma-joined string; the engine assembles it + // when calling into the bridge. We don't expose that string directly, + // but we can assert the inputs round-trip through the config. + XCTAssertEqual(config.extensionDirectories.map(\.path), + ["/tmp/ext-a", "/tmp/ext-b"]) + } +} + +// MARK: - Profile registry tests +// +// These are *integration* tests: CEFProfileRegistry.shared.profile(named:) +// calls into the ObjC++ bridge which calls CefRequestContext::CreateContext. +// CEF must be initialized for that to be defined behaviour. +// +// They are intentionally not in the regular unit-test target. To run them, +// expose a CMUX_CEF_INTEGRATION env var on a separate test target that +// initializes CEFEngine before tests start. +// +// Behaviour to verify (manually, today; programmatically in a later PR): +// * `CEFProfileRegistry.shared.profile(named:)` returns identical +// instances across calls with the same name. +// * Names beginning with "isolated-" produce profiles whose +// `isEphemeral` is true; named profiles return false. +// * Two profiles with different names produce distinct cache_path +// directories under the engine root. diff --git a/CEF/vendor/README.md b/CEF/vendor/README.md new file mode 100644 index 000000000000..b1348dc82552 --- /dev/null +++ b/CEF/vendor/README.md @@ -0,0 +1,98 @@ +# `vendor/` — CEF binary distribution provisioning + +Everything cmux needs to fetch, verify, extract, and prepare the Chromium +Embedded Framework binary distribution lives here. **One place** decides +*which* CEF version cmux uses (`cef.lock.json`) and *how* it's installed +(`fetch_cef.sh`). + +## Files + +| File | Purpose | +| --- | --- | +| `cef.lock.json` | Pinned CEF version + SHA1 + size + extracted directory name. The only authoritative source of "what CEF do we ship." | +| `cef.lock.schema.json` | JSON Schema for the lockfile. Editors and CI should validate against this. | +| `fetch_cef.sh` | Idempotent download + verify + extract + build wrapper. Called explicitly from `./scripts/setup.sh` and CI provisioning. | +| `README.md` | This file. | + +## Quick start + +```bash +# Download (or use cache), verify SHA1, extract, build C++ wrapper, populate +# the build's Frameworks/ dir. +vendor/fetch_cef.sh + +# Just verify the cached tarball still matches the lockfile. +vendor/fetch_cef.sh --verify + +# Print resolved paths and exit (used by Xcode build phases for input/output +# declarations). +vendor/fetch_cef.sh --print-paths +``` + +## Where the data lives + +| Path | Notes | +| --- | --- | +| `~/Library/Caches/cmux-cef-vendor//` | Per-developer cache. Survives `git clean`. CI uses `CEF_VENDOR_CACHE` to point at a cached artefact. | +| `/CEF//` | The unpacked CEF distribution. `` defaults to the repo's `CEF/` directory. | +| `/Frameworks/Chromium Embedded Framework.framework` | Re-laid-out, install-id-fixed, ad-hoc-sig-stripped framework. Ready to be re-signed by the cmux build with Developer ID. | +| `/Frameworks/libcef_dll_wrapper.a` | Static wrapper linked into the bridge. | +| `/Frameworks/include/` | CEF C++ headers. | + +The cache directory is per-version, so multiple cmux checkouts pointing at +different CEF versions don't fight each other. + +## Mirror / offline use + +In CI or behind restrictive networks, set `CEF_VENDOR_MIRROR` to an internal +HTTPS mirror (R2 / S3 / artefact registry) holding files at +`//`. The script tries the mirror first and falls +back to `https://cef-builds.spotifycdn.com/` if the mirror is unreachable. +If neither works *and* the cache is empty, the script exits non-zero. + +## Updating to a new CEF version + +1. Find the new version on https://cef-builds.spotifycdn.com/index.json. + Use the **standard** distribution for `macosarm64` (not `minimal`, + `client`, `tools`, or any of the symbol packs). +2. Update `cef.lock.json` with the new `version`, `tarball`, `sha1`, + `size_bytes`, and `extracted_dir_name`. Keep the JSON sorted and the + diff minimal. +3. Run `vendor/fetch_cef.sh`. It should download the new tarball and + verify it against the new SHA1. +4. Run cmux against the new CEF and exercise the runtime flow described in + `../INTEGRATION.md`. +5. Commit `cef.lock.json` in a separate PR titled `cef: bump to `. + Include the cef-builds release notes link in the PR body. + +CI gating: a nightly job verifies the lockfile by re-downloading from +`cef-builds.spotifycdn.com` and re-computing the SHA1. If the public CDN +mutates the artefact for the pinned version, the job goes red and a human +investigates before any cmux release. + +## Why we don't commit the binaries + +| | size | +| --- | --- | +| Tarball (compressed) | ~269 MiB | +| Extracted CEF dir | ~1.4 GiB | +| Frameworks/ output (binary only) | ~210 MiB | + +Committing any of these would blow up the cmux repo and the CI checkout +time. The lockfile is 0.5 KiB; that's what we version-control. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | Success. | +| 2 | Lockfile parse or argument error. | +| 3 | SHA1 mismatch on lockfile or downloaded artefact. | +| 4 | Network failure with no cache fallback. | +| 5 | C++ wrapper build failed. | + +## Source builds vs app runtime + +Source checkouts need this SDK because SwiftPM compiles against the CEF headers +and `libcef_dll_wrapper.a`. Installed cmux apps do not require users to run this +script: the app downloads and verifies the pinned runtime on first CEF use. diff --git a/CEF/vendor/cef.lock.json b/CEF/vendor/cef.lock.json new file mode 100644 index 000000000000..59db8694b2be --- /dev/null +++ b/CEF/vendor/cef.lock.json @@ -0,0 +1,21 @@ +{ + "$schema": "./cef.lock.schema.json", + "comment": "Pinned CEF binary distribution for cmux. Update via vendor/fetch_cef.sh --update.", + "channel": "stable", + "version": "146.0.10+g8219561+chromium-146.0.7680.179", + "platforms": { + "macosarm64": { + "tarball": "cef_binary_146.0.10+g8219561+chromium-146.0.7680.179_macosarm64.tar.bz2", + "sha1": "a483c800e506a592c63b60b36a12127eea3fc39f", + "size_bytes": 282101327, + "extracted_dir_name": "cef_binary_146.0.10+g8219561+chromium-146.0.7680.179_macosarm64" + } + }, + "sources": [ + { + "name": "spotifycdn", + "base_url": "https://cef-builds.spotifycdn.com/", + "kind": "public" + } + ] +} diff --git a/CEF/vendor/cef.lock.schema.json b/CEF/vendor/cef.lock.schema.json new file mode 100644 index 000000000000..f23c1f541269 --- /dev/null +++ b/CEF/vendor/cef.lock.schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/manaflow-ai/cmux/CEF/vendor/cef.lock.schema.json", + "title": "cmux CEF lockfile", + "description": "Pinned CEF binary distribution metadata. Sole source of truth for which CEF cmux ships.", + "type": "object", + "required": ["version", "channel", "platforms", "sources"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "comment": { + "type": "string", + "description": "Free-form human-readable note. Not consumed by tooling." + }, + "version": { + "type": "string", + "description": "Full CEF version string as published in the cef-builds index, e.g. '146.0.10+g8219561+chromium-146.0.7680.179'.", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+\\+g[0-9a-f]+\\+chromium-[0-9.]+$" + }, + "channel": { + "type": "string", + "enum": ["stable", "beta"] + }, + "platforms": { + "type": "object", + "additionalProperties": false, + "properties": { + "macosarm64": { + "$ref": "#/definitions/platform" + }, + "macosx64": { + "$ref": "#/definitions/platform" + } + }, + "minProperties": 1 + }, + "sources": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "base_url", "kind"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "base_url": { "type": "string", "format": "uri" }, + "kind": { "type": "string", "enum": ["public", "mirror"] } + } + } + } + }, + "definitions": { + "platform": { + "type": "object", + "required": ["tarball", "sha1", "size_bytes", "extracted_dir_name"], + "additionalProperties": false, + "properties": { + "tarball": { + "type": "string", + "description": "Tarball filename as served by the source. Used to compose the download URL.", + "pattern": "^cef_binary_.+_macos(arm64|x64)\\.tar\\.bz2$" + }, + "sha1": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "size_bytes": { + "type": "integer", + "minimum": 1 + }, + "extracted_dir_name": { + "type": "string", + "description": "Top-level directory name created when the tarball is extracted.", + "pattern": "^cef_binary_.+_macos(arm64|x64)$" + } + } + } + } +} diff --git a/CEF/vendor/fetch_cef.sh b/CEF/vendor/fetch_cef.sh new file mode 100755 index 000000000000..752de706b70f --- /dev/null +++ b/CEF/vendor/fetch_cef.sh @@ -0,0 +1,339 @@ +#!/usr/bin/env bash +# +# fetch_cef.sh — fetch, verify, extract, and build the CEF binary distribution +# pinned by cef.lock.json. Idempotent. +# +# Designed to be safe under: +# * incremental Xcode builds — exits fast when artefacts are already in place +# * CI parallelism — per-tag cache, atomic rename on extract +# * offline mode — if the tarball is already in the cache, no network +# +# Usage: +# vendor/fetch_cef.sh # default: build wrapper, place under default DEST +# vendor/fetch_cef.sh --dest # override DEST (where CEF lives for the build) +# vendor/fetch_cef.sh --no-build # download + extract only, skip cmake wrapper build +# vendor/fetch_cef.sh --verify # verify the lockfile hash against the cached tarball +# vendor/fetch_cef.sh --print-paths # print the resolved DEST and CEF version; exit 0 +# +# Exit codes: +# 0 success +# 2 lockfile parse / arg error +# 3 SHA1 mismatch (lockfile or downloaded artefact) +# 4 network error and no cache fallback +# 5 wrapper build failed +# +# Environment variables: +# CEF_VENDOR_CACHE override the cache dir (default: ~/Library/Caches/cmux-cef-vendor) +# CEF_VENDOR_DEST where the extracted CEF dir + Frameworks/ output live +# (default: