Turning a Local Dev Page into a Mac App — One Manifest and launchd, No Electron
Not an iframe, not a wrapper app. The browser gives the page its own window
There's a personal page I open every morning. It runs on a local Rails dev server. I wanted it to behave like a Mac app instead of a browser tab. Electron and Tauri came to mind first, but it ended with one web app manifest and one launchd job.
Follow along and a dev page like http://myapp.localhost:3000/ becomes its own window with a Dock icon.
What turns into an app
It's not an iframe, a box inside another page. The browser gives the page its own window: no tabs, no address bar, its own Cmd+Tab entry. The window is the page.
Three files and one line
Put the manifest and two icons in public/, then add one line to the layout's <head>.
{
"name": "My Desk",
"short_name": "Desk",
"start_url": "/",
"display": "standalone",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
<link rel="manifest" href="/manifest.json">
I got stuck twice.
I first named the file manifest.webmanifest. The Rails dev server served it as text/plain. Renamed to manifest.json, it came back as application/json.
I also assumed a service worker was required for the install button. It wasn't. On 2026-10-01, macOS 14.6.1 with Chrome 154, the localhost page received beforeinstallprompt with no service worker at all. Installing created an app under ~/Applications/Chrome Apps/. No service worker means no cache, so code changes show up in the window as usual. Only manifest changes such as the name or icon reach Chrome late.
If you add your own install button
prompt() on beforeinstallprompt works once. Open it, dismiss it, and the button stays dead until a reload. Once the chance is used, I show a hint instead: the install icon at the right of the address bar, or the ⋮ menu.
let deferred = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferred = e;
installButton.hidden = false;
});
installButton.addEventListener('click', async () => {
if (!deferred) { showMenuHint(); return; }
deferred.prompt();
await deferred.userChoice;
deferred = null; // can't be used twice
});
The install confirmation appears outside the page, in the browser's own UI. Automation such as an extension can't press that final "Install". A person does.
launchd starts the server
A Dock icon is useless if the server is down, so launchd starts the dev server at login.
Our repo's start command (make lad) first runs kill -9 on whatever holds the port. So the job starts it only when the port is free. AbandonProcessGroup keeps the server alive when the launchd job itself exits.
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string><string>-lc</string>
<string>lsof -iTCP:3000 -sTCP:LISTEN || (cd ~/src/myapp && make lad)</string>
</array>
<key>RunAtLoad</key><true/>
<key>AbandonProcessGroup</key><true/>
Next to Electron and Tauri
| Approach | What it takes | What you get |
|---|---|---|
| install via manifest | 3 files + one link tag | Dock icon, window without address bar, Cmd+Tab |
| Tauri or Electron wrapper | new project, builds, signing | menu bar, global shortcuts, remembered window state |
| standalone app bundling the server | ship Rails and the DB | offline use |
A wrapper still shows the same page inside. If you truly need a menu bar icon or global shortcuts, go Tauri. For a personal tool that just has to open from the Dock, the manifest is enough. Another build and signing pipeline is only more upkeep.
Not checked yet: whether Safari's "Add to Dock" picks up the same manifest, and what the window shows when the server is down.
If you want an internal admin page or a personal tool to feel like an app and aren't sure how far to build, ask us at https://www.negabaro.com/contact.
How It Works
Put manifest.json and 192/512 icons in public/, add one link tag to <head>
Install the page as an app in Chrome: Dock icon and its own window
launchd starts the dev server at login, only if the port is free
Pros
- ✓ No build or signing; code changes show up as usual
- ✓ Installed without a service worker
Cons
- ✗ No menu bar icon or global shortcuts
- ✗ A person has to press the final install button