Skip to content

Repository files navigation

Proton

Proton is a MoonBit framework for building native desktop applications with a web frontend.

Supported prebuilt runtimes:

  • Windows x64
  • macOS Apple Silicon
  • Linux x64

Quick start

Install the CLI and create a project:

moon install justjavac/proton_cli
proton_cli new my-app \
  --title "My App" \
  --identifier "com.example.my-app"
cd my-app

Fetch the MoonBit dependencies, install the Warren frontend toolchain, and set up the native runtime:

moon update
moon install moonbit-community/warren
proton_cli cef setup

Start development:

proton_cli dev

The generated project is a three-module workspace: shared/ holds the typed command and event contracts used on both sides, frontend/ is a Rabbita application built and served by Warren, and backend/ runs the Proton desktop runtime. .proton/ is a local runtime cache and should not be committed.

Application entry

Generated projects explicitly load moon.proton with @proton.config(...) and register their typed commands in backend/app/main.mbt:

fn main {
  @proton.run(() => {
    let backend = @todo.Backend::new()
    @proton.config("moon.proton")
    .commands(fn(registrar) raise { backend.register_commands(registrar) })
    .run_or_abort()
  })
}

Commands are declared once in the shared/ contract package and wired into the runtime with .commands(...); the frontend invokes them through the typed Rabbita client and can subscribe to events pushed by the backend.

The default config name honors PROTON_CONFIG_PATH (including proton_cli dev --config) and resolves the packaged config location when the application is bundled. Non-default paths are used exactly as provided.

For a small application, inline HTML can be opened directly:

fn main {
  @proton.run(() => {
    @proton.html(
      "Hello Proton",
      "<h1>Hello from MoonBit</h1>",
      width=900,
      height=700,
      debug=true,
    ).run_or_abort()
  })
}

The root package also supports URL, file, asset, and project-config entries through @proton.url, @proton.file, @proton.asset, and @proton.config.

Renderer permissions

Registering backend commands does not expose them to a renderer. Every renderer capability requires a grant for one window, one trusted source, and one extension. Missing grants are denied.

Commands registered directly with .commands(...) belong to the app permission id. Generated projects declare that grant in moon.proton:

permissions = [
  {
    window: "main",
    origin: "entry",
    extension: "app",
  },
]

origin: "app" names bundled proton://app content. origin: "entry" follows the configured entry and resolves URL entries to their exact HTTP(S) origin, including frontend.dev_url during development. Arbitrary origins cannot be granted.

For extensions without an additional scope, .expose(extension) is the explicit shorthand for registration plus an empty grant. Filesystem access must declare path ranges and exact commands:

@proton.html("Files", html)
.extension(@fs.extension())
.permission(
  @fs.permission([
    @fs.PermissionRoot::new("./workspace", [
      "read_file",
      "write_file",
      "readdir",
    ]),
  ]),
)

The renderer cannot select or widen these roots. Proton matches the trusted frame origin in native code, rechecks the grant during MoonBit dispatch, and the filesystem extension validates the canonical target before each operation. Relative filesystem roots and request paths are anchored to the directory containing moon.proton; apps configured entirely in MoonBit use the working directory captured during startup.

On macOS and Windows, web content can extend beneath the native titlebar while retaining the system window controls. Set titlebar_style in moon.proton:

window = {
  title: "My App",
  width: 900,
  height: 700,
  titlebar_style: "overlay",
}

titlebar_style accepts "default" and "overlay". Overlay rendering is implemented and shipped for macOS and Windows. Linux keeps the default titlebar. On Windows, Proton consumes CEF's native draggable-region updates. Set -webkit-app-region: no-drag on interactive descendants, then assign element.style.webkitAppRegion = "drag" to the draggable container after it exists in the DOM. The post-DOM assignment is required by the currently shipped CEF build to emit its initial region update; later changes are reported directly by CEF. These are CEF-provided regions, not an Electron compatibility shim. Until the page reports its first region update, Proton keeps a small DPI-aware leading drag fallback. Pages must also reserve the native caption-button area. Overlay windows request DWM's dark caption appearance so the native controls blend with dark application chrome. Typed window configs send titlebar_style only when the loaded runtime reports the titlebar_overlay feature. Older prebuilts and unsupported platforms omit the field and retain their default titlebar behavior. See examples/48_titlebar_overlay for a cross-platform overlay layout example.

size_hint accepts "none", "fixed", "min", and "max". A fixed window cannot be resized; minimum and maximum hints constrain resizing relative to the configured width and height.

Code-only apps can select the same style through the facade:

@proton.html("My App", html)
.titlebar_style(@proton.TitlebarStyle::Overlay)

The typed facade can own additional windows without replacing Proton's bridge pump:

@proton.html("Main", main_html)
.add_window(
  "details",
  "Details",
  @proton.AppEntry::Html(details_html),
  width=640,
  height=480,
  open_on_start=false,
)
.app_lifecycle(
  on_start=async fn(context) {
    let details = context.windows().open("details")
    details.set_position(80, 80)
    details.set_zoom_percent(110)
  },
  on_shutdown=fn(_) {  },
)

Runtime-created windows must be declared before startup so packaging inputs, origins, and permissions remain explicit. open_on_start=false declares a template without creating it; WindowManager::open creates a fresh concrete instance when the application needs it. WindowHandle supports show, hide, focus, close, title, size, position, minimize, maximize, restore, fullscreen, always-on-top, zoom, and a WindowState snapshot containing the current monitor, work area, scale factor, focus, and theme.

Window state and close requests are delivered by the managed runtime session:

@proton.app()
.on_window_event(async fn(window, event) noraise {
  match event {
    StateChanged(state) => println(window.id() + ": " + state.theme)
  }
})
.on_window_close_request(async fn(_window) noraise {
  @proton.WindowCloseDecision::Allow
})

Close handlers run asynchronously without blocking the native UI thread. WindowHandle::close follows the same cancellable request path; session cleanup uses the owning destroy lifecycle. The process remains active until every concrete window has closed. See examples/45_bridge_multi_window.

Enable operating-system single-instance routing with a stable application identifier:

identifier = "com.example.my-app"
single_instance = true

When another process starts, Proton forwards its protocol URLs and document paths to the primary process before creating CEF, then exits. The primary process restores and focuses its application window before delivering the typed activation:

@proton.config("moon.proton")
.on_launch_input(async fn(input) noraise {
  match input {
    OpenUrls(urls) => ...
    OpenFiles(paths) => ...
    Reopen => ...
  }
})

The instance coordinator is implemented on macOS, Windows, and Linux. Packaged macOS applications register bundle.url_schemes and bundle.document_types through Info.plist. Windows portable ZIPs and the current Linux build do not install operating-system associations; their proton-package.json metadata is intended for a future installer/package target. Direct launches and associations installed by another package manager still use the same forwarding path.

Use @proton.app_data_dir("com.example.my-app") to resolve the stable native data directory for an application identifier. The function does not create the directory.

Headless automation

Code-driven applications can run with CEF off-screen rendering and no native top-level window:

@proton.config("moon.proton")
.headless()
.run_or_abort()

Set PROTON_HEADLESS=1 to force the same mode in automated runs without changing application code. Headless mode is independent of remote debugging, so CDP can be enabled separately for end-to-end tests. Native menus, dialogs, and titlebar overlay are unavailable in this mode. Linux still requires an X11 display; use Xvfb in display-less CI jobs.

Frontend projects

Generated projects describe their toolchain in moon.proton:

backend = {
  path: "backend",
  package: "app",
}

frontend = {
  path: "frontend",
  dev_url: "http://127.0.0.1:4300",
  before_dev: "warren dev --port 4300",
  before_build: "warren build",
  dist: "dist",
}

entry = {
  kind: "asset",
  value: "frontend/dist/index.html",
}

backend selects the MoonBit package that runs the Proton runtime. entry selects what the main window loads: kind is "html", "url", "file", or "asset", and file/asset values resolve relative to the config file. The frontend block drives development and build orchestration: path is the frontend working directory, before_dev/before_build run there, dev_url is the development server to wait for, and dist is the build output to validate (resolved relative to path).

proton_cli dev runs frontend.before_dev, waits for frontend.dev_url, and launches the app in development mode. proton_cli build runs frontend.before_build, validates frontend.dist, and builds the MoonBit app for the native target. Vite, Next, and similar tools fit the same shape: point path, before_dev, before_build, dev_url, and dist at the equivalent npm scripts.

Build

moon check --target native --diagnostic-limit 80
proton_cli build
proton_cli build -- --release

Arguments after -- are passed to moon build; Proton always selects the native target.

E2E tests

The native bridge E2E suite is implemented in MoonBit and owns its application processes, CDP connections, frontend servers, and cleanup:

PROTON_NATIVE_DIST="$PWD/native/dist" \
PATH="$PWD/native/dist/bin:$PATH" \
moon -C e2e test -p justjavac/proton/e2e/test \
  --target native --no-parallelize --diagnostic-limit 200

For an application that is already running with CDP enabled, use the typed driver instead:

MBT_PROTON_E2E_SCENARIO=41_app_commands \
MBT_CDP_TARGET=9222 moon -C e2e run test --target native

Bundle and package

The bundle block in moon.proton enables package creation and selects its default targets and output directory:

single_instance = true

bundle = {
  active: true,
  targets: ["app", "zip"],
  url_schemes: ["my-app"],
  document_types: [
    {
      name: "Text document",
      extensions: ["txt", "md"],
      role: "Editor",
    },
  ],
  output: "target/proton-dist",
}

Inspect the resolved bundle plan before creating artifacts:

proton_cli package --dry-run
proton_cli package

The package command performs a release build unless --no-build is supplied. Package output is written to target/proton-dist by default. Icons, resources, output targets, signing, notarization, custom URL schemes, and macOS document types are configured through moon.proton and package command options.

The dmg target is available on macOS. It creates a compressed disk image containing the app and an /Applications shortcut for drag-to-install:

proton_cli package --target app --target dmg

With --notarize, Proton submits the DMG when that target is enabled, then staples and validates both the DMG and the app before creating any requested ZIP archive. Without a dmg target, the existing app notarization flow is used. Windows supports the app and zip targets.

Diagnose a project

proton_cli doctor
proton_cli doctor --deep
proton_cli doctor --frontend

Run proton_cli cef setup again when the active runtime is missing or invalid. Use PROTON_CEF_LOG=default temporarily when browser-runtime logs are needed.

See examples/Readme.md for runnable examples. Repository contributors and release maintainers should follow AGENTS.md.

License

Apache License 2.0. See LICENSE.md.

About

A MoonBit framework for building native desktop applications with a web frontend

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages