Architecture

A Rust workspace layered bottom-up, with one Tokio-to-gpui seam.

crates/
  model     shared domain types (the wire contract)
  store     ~/.hopper/ JSON persistence + OS keychain
  docker    Engine API client (hyper) + every domain module
  apple     Apple Containers, driven through the `container` CLI
  engine    provider abstraction; Apple / Docker-or-Podman / existing
  migrate   Docker -> Hopper import
  host      the async service facade the UI calls
  mcp       the stdio MCP server
  app       the gpui + guise application
  site      this documentation site's generator

Each crate depends only on those below it, and the gpui-free core never imports gpui. The boundary is app.

Two engines, one enum

Hopper speaks to two kinds of engine: the Docker Engine API over its native transport, and Apple's runtime over its CLI. host::runtime::Backend picks between them.

It is an enum, not a trait, and that is deliberate. The streaming calls take closures — FnMut(LogLine) -> bool — which are not object-safe. Boxing every callback to pretend a trait works would buy nothing over a two-variant match.

EngineCapabilities carries what each backend can actually do, and the UI reads it: unavailable tabs are not drawn, unavailable routes leave the sidebar.

The async seam

The Docker layer runs on a Tokio runtime; gpui has its own executor. They meet in exactly one place, app::bridge, which runs a future on Tokio and delivers the result on the gpui main thread through a runtime-agnostic oneshot.

Streaming works the same way: a producer sends items down a channel, and the view that stops receiving is how a log follow gets cancelled. There is no separate abort registry to keep in sync.

This mirrors the tables architecture.

Why the CLI, not the API

Apple's container talks to container-apiserver over XPC and publishes no Docker Engine API. The request to expose one was closed as not planned. So crates/apple drives the binary and maps its JSON onto the same model types the Engine API path produces — which is why no view knows which backend answered.

That mapping is deliberately liberal: Apple promises stability only within a patch version, so every field is optional and anything structural Hopper does not need stays a Value. A field Apple renames should cost one column in a list, not the whole view.