The outerframe is a hosted platform. The host downloads a dynamically loaded library, loads it into a sandboxed process, embeds the rendered result into a rectangular region of its own UI, and exchanges messages with the content process for user interaction events and host-brokered APIs.

The sandboxed process renders the contents, but the host remains responsible for the boundary. It decides what the content can access directly, what must go through a proxy, and what must be mediated by native host UI.

On macOS, the outerframe content process gets:

  • a sandboxed process
  • a socket connection to the host
  • a way to register a root CALayer
  • credentials for a proxy server that is its only access to the internet
  • a host-provided temporary staging directory for file exchange

Outerframe content on macOS is therefore not “a tiny AppKit app.” AppKit is not designed for running UI in a separate process. Instead, content renders through lower-level primitives and asks the host to perform privileged native operations.

The core outerframe API intentionally stays small. It does not try to be a complete UI framework. You can build or bring a framework on top of it, or you can render directly using the lower-level platform APIs.

The binary blob

The decision to use the macOS outerframe starts with the cross-platform HTTP handshake.

For this macOS implementation, I made it as Apple-native as possible; the “compiled code” is a “.bundle.aar” file, i.e. a compressed Apple Archive containing a loadable NSBundle. The outerframe code extracts this archive and a heavily sandboxed non-UI process “OuterframeContent” loads the bundle and asks it to start rendering.

Rendering

The philosophy of the outerframe is to lean on the operating system’s UI APIs, but it’s important to note that not all UI frameworks are compatible with rendering in a sandboxed separate process. Neither SwiftUI nor AppKit’s NSViews can be used. Outerframe content still uses the underlying operating system, but it must use lower level primitives.

In my original implementation, the outerframe content code received a framebuffer (a.k.a. graphics context or IOSurface), and populated it. To use an analogy from the current web, this is similar to making the page a single <canvas/> and rendering everything with WebGL.

That approach is perfectly valid, but on macOS your potential is limited if you use a framebuffer. The Firefox blog discusses this. One fundamental issue is that you can’t tell the macOS compositor (WindowServer) about small updates, you can only invalidate the entire framebuffer.

A core UI component of Apple’s platforms is the CALayer, a higher-level abstraction that gives you access to framebuffers when you want them. CALayers form a tree/hierarchy. By embracing the CALayer, you solve the problem above, plus you get a bunch of animation functionality that runs completely in the operating system’s compositor, not even waking up your threads. For example, in the Top video above, when the process rows animate up and down, Outer Loop and OuterframeContent don’t need to do frame-by-frame updates; the threads simply sleep through the entire animation. And, of course, embracing this higher level primitive gives us a lot more functionality for free, which is convenient. There are a few kinds of CALayer, including a CAMetalLayer if you want to use Metal GPU shaders.

To use a remote-rendered CALayer, the outerframe uses the CALayerHost, a private API. I hope Apple someday makes a public API analogous to CALayerHost, as it is vital to having efficient sandboxed UI (Safari and Chrome also use it). For the reasons above, the public API IOSurface is less efficient and much less powerful.

So the macOS outerframe rendering model is: your binary registers a CALayer (which is typically the root of a tree of CALayers) and keeps it up-to-date. You can read more about the binary interface here.

Events, operating system UI

Of course, the rendering layer is just part of an app platform. Other parts include mouse events, keyboard input, interacting with the IME (e.g. the operating system’s overlay UI that converts Pinyin to Chinese characters), text editing hotkeys, accessibility, native blinking carets, Dark Mode, context menus, copy/paste, and so on. For each of these, the Outerframe mirrors the underlying operating system, passing events from the browser down to the content, and letting the content code send messages back. All of the above is implemented, but there are more things in this category that are not implemented yet. The current set of messages is documented here.

This is an area where Apple would be able to create a nicer API than I can, since they could modify the operating system.

Networking

The outerframe content code has access to macOS’s built-in networking APIs, but it is sandboxed to have no direct access to the network. Instead, it connects through a local proxy provided by the outerframe. That proxy enforces same-origin policies, so the content doesn’t have unfettered access to the network. It also gives the hosting app the ability to connect to other things. For example, Outer Loop uses this proxy to let the outerframe connect to servers over SSH, and to connect to local Unix socket files.

When you use macOS’s URLSession API, configure it to use the host-provided local proxy. The proxy configuration is delivered during initialization.

The proxy configuration includes a host, port, username, and password. The credentials are generated for a single content launch and authorize the content process to use only the routes the host registered for that outerframe app. Treat them as process-local secrets: use them for the proxy connection, but do not persist them.

let configuration = URLSessionConfiguration.default
configuration.connectionProxyDictionary = [
    kCFNetworkProxiesHTTPEnable as String: true,
    kCFNetworkProxiesHTTPProxy as String: proxyHost,
    kCFNetworkProxiesHTTPPort as String: proxyPort,
    kCFProxyUsernameKey as String: proxyUsername,
    kCFProxyPasswordKey as String: proxyPassword
]
let session = URLSession(configuration: configuration)

Synchronous APIs

Some AppKit APIs are naturally synchronous. Menu validation needs an immediate answer about which edit commands are enabled. Drag-and-drop hit-testing needs an immediate answer about whether the current point accepts the drag. Accessibility queries may need a current tree while the host is answering the operating system.

The outerframe chooses to embrace this shape where it matches the operating system. For example, the host can send editCommandValidationRequest, pasteboardDropHitTestRequest, or accessibilitySnapshotRequest and wait for the matching response.

The safeguard is that every synchronous request has a timeout. If content does not answer quickly, the host falls back to a conservative result, such as disabling the menu item, rejecting the drag location, or returning no accessibility snapshot. Synchronous messages should be reserved for query-shaped operations that content can answer from current UI state.

Bring your own UI framework

outerframe is intentionally a minimal platform. It provides the core host/content boundary and leaves higher-level UI choices to content.

There are a few things you may expect from a web view that the outerframe doesn’t provide out-of-box. It has the building blocks to build everything you need, but it doesn’t pre-stack those building blocks together for you. There’s no notion of a scrollable page, there’s no single “correct” way to layout text and perform text selection.

Rather than providing one standard library for all of this, outerframe content can build the abstractions it needs on top of the socket protocol and the native platform.

Text input and keypresses

Similar to scrolling / zooming, text input is an area where it’s usually best to lean on the operating system as much as possible. We rely on the operating system to take raw events (e.g. option-shift-left-arrow) and convert them to text commands (e.g. “highlight previous word”), and we let it handle IME input for non-English text.

For raw input events, the host sends key events derived from keyDown and keyUp. Content can also request text-input mode. In text-input mode, the host uses the native text input system and forwards operations such as inserting text, setting marked text, unmarking text, and performing text commands.

Content should switch input mode through the outerframe socket protocol. Use raw-key mode for game-like controls and keyboard shortcuts that content wants to interpret itself. Use text-input mode when a text field or editor has focus.

Copy/paste

Outerframe content does not talk to NSPasteboard directly. The host owns native pasteboard access and converts between AppKit pasteboard objects and outerframe pasteboard messages.

The outerframe pasteboard model mirrors NSPasteboardItem:

OuterframeContentPasteboardItem
  representations: [OuterframeContentPasteboardRepresentation]

OuterframeContentPasteboardRepresentation
  typeIdentifier: String
  data: Data

One outerframe pasteboard item becomes one native pasteboard item. Each representation becomes one native pasteboard type on that item. This preserves the common AppKit shape where a single item may have several equivalent representations, such as plain text, RTF, HTML, and a custom app type.

Copy and cut

Copy and cut are user-initiated host actions.

  1. The user chooses Copy or Cut.
  2. The host sends selectionToPasteboardCopyRequest or selectionToPasteboardCutRequest.
  3. Content replies with selectionToPasteboardResponse.
  4. The host writes the returned items to NSPasteboard.general.

The host asks content which edit commands are currently enabled with editCommandValidationRequest, and content replies with editCommandValidationResponse. This mirrors AppKit’s synchronous menu validation shape, but the request is batched so one response can validate a whole menu pass.

For Cut, content should both return pasteboard items and remove the selected content.

Paste

Paste is also user-initiated.

  1. The user chooses Paste.
  2. The host reads NSPasteboard.general.
  3. The host filters pasteboard items to the accepted types content has advertised.
  4. The host sends pasteboardContentPasted.

Content opts into host-delivered paste by sending setAcceptedPasteboardPasteTypes(...). An empty type list disables host-delivered paste.

Programmatic pasteboard reads and writes are a separate capability. Content can request them through pasteboardAccessRequest, and the host can apply policy before replying.

Drag and drop

Drag and drop follows the same rule as paste: content never uses the native pasteboard service directly. The host owns NSDraggingSession, NSDraggingDestination, NSPasteboard, and NSFilePromiseProvider.

Dragging out

Content starts a native drag by sending beginDraggingPasteboardItems. Each dragged item carries pasteboard representations and may include a PNG preview image. The preview image affects source-side drag UI only; it is not written to the pasteboard and is not visible to the destination.

For small data such as text, images, URLs, or custom pasteboard types, content can provide ordinary pasteboard representations.

For files, content should usually use a file promise:

org.outerframe.file-promise

A file promise lets the native drag begin immediately. If Finder or another native destination accepts the promise and asks for the file, the host sends filePromiseWriteRequest. Content then writes or downloads the file into the host-provided staging directory and replies with filePromiseWriteResponse. The host copies the staged file into the native destination.

This avoids moving large file bytes for a drag the user cancels.

Dragging in

Content opts into drops with setPasteboardDropBehaviorUniform(...) or setPasteboardDropBehaviorHitTest().

Uniform drop behavior accepts matching pasteboard types anywhere in the outerframe view. Hit-tested drop behavior asks content whether the current view-local point accepts the drag. The host sends pasteboardDropHitTestRequest during drag tracking, and content replies with an operation mask. A zero mask rejects that location.

When the user drops local files, the host does not reveal the original filesystem paths to content. Instead:

  1. The host reads the native dragging pasteboard.
  2. The host clones or copies each dropped file into the staging directory.
  3. The host sends content a pasteboardContentDropped message.
  4. Dropped files are represented using:
org.outerframe.dropped-file-access

The dropped-file-access payload contains metadata, a staged local path, and an access ID. Content reads the staged file from inside its sandbox. When content is done, it sends releaseDroppedFileAccess(accessID:), allowing the host to remove that temporary access directory.

Temporary staging directory

The host provides a temporary staging directory for file exchange. The content process receives it as:

OUTERFRAME_STAGING_DIR

Use this directory for:

  • promised files that content creates for drag-out,
  • files that content wants to put on the general pasteboard as public.file-url,
  • host-staged files delivered from native drops.

Do not treat this as persistent app storage. It is temporary exchange space. A host may prune it, and content should not store durable references to paths inside it.

For hosted launches, this directory is also the content process’s TMPDIR. Code that uses FileManager.default.temporaryDirectory, NSTemporaryDirectory(), or OUTERFRAME_STAGING_DIR should land in the same origin-scoped temporary area. The host may still reserve documented subdirectories inside it, such as dropped-file-access, for host-managed file leases.


Table of contents