Proton is a MoonBit framework for building native desktop applications with a web frontend.
Supported prebuilt runtimes:
- Windows x64
- macOS Apple Silicon
- Linux x64
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-appFetch the MoonBit dependencies, install the Warren frontend toolchain, and set up the native runtime:
moon update
moon install moonbit-community/warren
proton_cli cef setupStart development:
proton_cli devThe 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.
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.
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 = trueWhen 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.
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.
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.
moon check --target native --diagnostic-limit 80
proton_cli build
proton_cli build -- --releaseArguments after -- are passed to moon build; Proton always selects the
native target.
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 200For 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 nativeThe 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 packageThe 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 dmgWith --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.
proton_cli doctor
proton_cli doctor --deep
proton_cli doctor --frontendRun 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.
Apache License 2.0. See LICENSE.md.