Turn any localhost web app into a real macOS app.
You have a project that runs on localhost:3000. mkmacapp wraps it in a
native .app bundle — its own icon, its own Dock entry, its own window and
menu bar. No browser tab, no address bar, no remembering which port it was on.
cd ~/my-project
mkmacapp init # writes macapp.json, guessing from your stack
mkmacapp --install # builds it and drops it in /ApplicationsThat's the whole workflow. Your app is now in Spotlight and your Dock.
A .app bundle containing a small Swift/WKWebView launcher. On open it picks
a port, starts your server on it, waits for the port to accept connections,
then loads your UI in a native window. On quit it terminates the server.
There is no Electron here, and no bundled browser engine. The launcher is ~300 lines of Swift against the system WebKit, so a bundle adds a few hundred kilobytes rather than a hundred megabytes.
macOS, and Xcode Command Line Tools for swiftc:
xcode-select --installgit clone https://github.com/elev8tion/macapp.git
cd macapp
./install.sh # into ~/.local/bin
./install.sh --link # or symlink, so `git pull` updates the toolmkmacapp init picks one for you based on the project. Override it in
macapp.json if the guess is wrong.
Best for Node, Python, Bun, or anything with a dev server. Nothing is copied into the bundle; the app runs your project where it already lives.
{
"name": "notes",
"command": "npm run dev",
"port_env": "PORT",
"addr_template": "{port}"
}Your node_modules stays where it is, and edits to the project are live the
next time you open the app — no rebuild. The trade-off: move or delete the
project directory and the app stops working.
Best for Go and Rust. The binary is built and copied into the bundle, so the app keeps working even if the source tree is gone.
{
"name": "ledger",
"build": "go build -o {out} ./cmd/app",
"port_env": "ADDR",
"addr_template": "127.0.0.1:{port}",
"resources": ["static", "templates"]
}Anything read from disk at runtime must be listed in resources (copied into
Contents/Resources, which is the server's working directory) or embedded in
the binary.
For when you already have an executable or a launch script.
{
"name": "dashboard",
"exec": "launch.sh",
"resources": ["server.js", "public"],
"port_env": "",
"args": ["--port", "{port}"]
}The only field that always needs your attention is how your server takes its port. Read your own startup code and match it:
| Your server reads | Set |
|---|---|
process.env.PORT |
"port_env": "PORT", "addr_template": "{port}" |
os.Getenv("ADDR") |
"port_env": "ADDR", "addr_template": "127.0.0.1:{port}" |
a --port flag |
"port_env": "", "args": ["--port", "{port}"] |
Everything else has a default that works:
| Field | Default | Notes |
|---|---|---|
preferred_port |
"auto" |
Assigns a stable port from ~/.mkmacapp/ports.json (41000+) on first build and keeps it. URLs survive restarts; two apps never collide. A number pins it, 0 forces a random port. |
health_path |
"" |
Empty waits for the port to accept a TCP connection — correct regardless of routes, redirects, or auth. Set a path only if your port opens before the app can serve. |
icon |
assets/icon.png |
Any square PNG ≥512px. If the file is missing, an icon is generated from the app name — a colored tile with its initials, hue derived from the name so every app looks distinct. |
start_path |
"/" |
The route the window opens on. |
window |
[1280, 840] |
Initial size. The window remembers its position after that. |
identifier |
local.<name>.app |
Bundle identifier. |
cwd |
project dir | command mode only — where to run the command. |
The launcher is not a bare web view. It wires up the things that silently break otherwise:
- File pickers —
<input type="file">renders nothing in a plain WKWebView without a delegate. - Downloads —
Content-Disposition: attachmentis dropped silently without a download delegate. Files land in~/Downloadsand get revealed in Finder. - JS dialogs —
alert,confirm, andpromptbecome native panels. - Menu bar — with working Cut/Copy/Paste/Select All and ⌘R to reload.
- Clean shutdown — quitting terminates the server. The server is also handed a pipe on stdin, so a server that watches stdin can shut itself down even if the app is force-quit.
- Ad-hoc signed. These bundles are signed with
codesign -s -, which is fine on the machine that built them. Sending one to someone else needs a Developer ID and notarization — out of scope here. commandmode is not portable. It points at a path on your disk. Usebuildmode for anything you want frozen.- Finder does not read your shell profile.
commandmode bakes the build-timePATHinto its launcher sonode,python3, and friends resolve. A hand-writtenexecscript has to handle that itself. - White window on launch means the server never started listening. Run your start command in a terminal with the port set the same way and read the error.
skills/macapp/SKILL.md is a Claude Code
skill. Install it with ./install.sh --skill, then say /macapp in a
project and Claude will read how the server starts and write macapp.json
for you.
MIT