From browser tab to Mac app — what we tried, what worked
The goal was simple: turn the dashboard from "a tab in your browser" into "a real Mac app." The first thing we built isn't what's running today. We tried Tauri, hit a cascade of four browser-API divergences in four hours, then ran into a fifth that would have meant refactoring 28 dashboard buttons. We pivoted to Safari "Add to Dock," shipped that in 45 minutes, and Tauri stays in the repo as a future option. Here's the build, the cascade, and the lesson about what "build once" actually means when the thing you're wrapping is a browser app.
The original decision
Two real choices for "make this Mac-app-shaped":
- Safari "Add to Dock" — a built-in macOS Sonoma+ feature. ~30 minutes to ship: write a PWA manifest, add a few
apple-mobile-web-app-*meta tags, click File → Add to Dock. Creates a standalone window with native traffic lights, no URL bar, no tabs. The "easy" path. - Tauri 2 — a Rust-based webview wrapper. ~2-3 days to ship: scaffold a Cargo project, write a
tauri.conf.json, build a.app. Same native WKWebView rendering as Safari, but with full control: custom menu bars, code-signed binary, auto-updater, ability to spawn the Python server as a child process.
We picked Tauri. The framing was "build once, do it right rather than ship the easy thing and rebuild later." The framing was wrong — not because Tauri is bad, but because we underweighted what we were wrapping.
The build (day 1)
The first .app took about 2 hours of focused work. Install cargo tauri-cli, hand-scaffold tauri-app/src-tauri/ with Cargo.toml, tauri.conf.json, a minimal main.rs, and an icons/ directory copied from the existing iconset. Point the window URL at http://localhost:8001/swingtrading_dashboard.html. Run cargo tauri build. Out comes a 2.9 MB Swing Deck.app.
Drop it in /Applications. Double-click. The window opens. The dashboard renders. Native macOS traffic lights, no browser chrome. Looked perfect.
Then I clicked "Connect E*Trade." Nothing happened.
The cascade: four patches in four hours
Patch 1 (v7.8.67) — window.open
"Connect E*Trade" calls window.open(authorize_url, '_blank', 'noopener') to open the OAuth flow in a new tab. WKWebView's default popup policy blocks window.open. Silent failure. No console error, no popup, nothing.
Fix: monkey-patch window.open via Tauri's initialization_script to route external URLs through tauri-plugin-shell's open() command. About 30 minutes. The OAuth flow now opens in the user's system default browser.
Same fix covers all 7 window.open call sites in the dashboard — E*TRADE OAuth, Stripe checkout, Stripe portal, share buttons, swing-deck.com links. Felt clean. Moved on.
Patch 2 (v7.8.71) — navigation interception
User: "none of the links are working either." Different problem. <a href="..."> clicks don't go through window.open — they go through the webview's navigation layer, which is a separate event. The window.open monkey-patch doesn't catch them.
Fix: add an on_navigation closure on the WebviewWindowBuilder that intercepts every navigation request, allows localhost (the dashboard's own origin), and routes external URLs through macOS's open command via std::process::Command. Another 30 minutes.
Now external URLs work via both paths (window.open AND anchor clicks). The Reddit post, the Stripe link, the GitHub link — all routing correctly to the system browser.
Patches 3 + 4 (v7.8.69 → v7.8.70) — plugin ACL
The window.open override was firing but the OAuth still failed. DevTools said:
Unhandled Promise Rejection: Command plugin:shell|open not allowed by ACL
Tauri 2 has a strict capability system. tauri-plugin-shell's open command requires a URL-pattern scope grant, and the scope syntax is version-dependent (regex vs glob vs URL allowlist) and undocumented in a way that made the right shape hard to find. Swapped to tauri-plugin-opener instead — a newer Tauri 2 plugin purpose-built for "open URL in default browser," with no scope-ACL trap.
That worked. OAuth flow finally opened the browser cleanly. Four patches in four hours. The standalone Mac app worked.
Then we tested the rest of the dashboard.
The fifth divergence
User report: "none of the restart buttons work either."
28 buttons in the dashboard gate on if (!confirm(...)) return;. In a browser, window.confirm() shows a native modal dialog and returns true or false based on the user's click. In Tauri's WKWebView (with default UI delegates), window.confirm() silently returns false. No dialog appears. The 28 buttons all "do nothing."
This is where the wall came up. The window.open and navigation fixes were one-line interventions per concern. The confirm fix isn't — window.confirm() is one of the only synchronous primitives in JavaScript. You cannot polyfill it with an async modal. You cannot use Tauri's dialog plugin (async, returns Promise) as a drop-in. The honest options were:
- Refactor all 28 dashboard sites from
if (!confirm(...))toif (!await asyncConfirm(...))and make every calling function async. ~4 hours of careful work, ripples into anything that calls those functions, breaks the dashboard's voluntary-async pattern. - Live with the limitation. 28 dashboard buttons silently fail in the Tauri app. Bad product.
- Try to enable WKWebView's native dialog handling via Tauri config. Maybe an hour of probing, no guarantee of success — the Tauri/wry layer doesn't expose a knob for this on macOS 15+.
Plus the strong suspicion that this wouldn't be the last divergence. The dashboard uses 59 alert() calls, 34 confirm(), 14 prompt(). It uses the Notification API. It uses the clipboard. It has drag-and-drop in a few places. Every one of those is a potential next-week's-Tauri-bug. The pattern wasn't going to stop.
The pivot
Stepped back. The honest reframe:
"Build once" is a function of what you're wrapping. If you're wrapping an app built FOR Tauri (where the dev knows the constraints from day 1), Tauri is build-once. If you're wrapping a complex browser app that uses browser idioms heavily, the cleanest build-once is a real browser PWA install — because the wrapper IS a browser, so every browser API works natively.
Safari "Add to Dock" took 45 minutes to ship after the pivot: write a manifest.json, add 5 meta tags to the dashboard's <head>, drop the icons in the right paths, whitelist them in the static-file allowlist. Open Safari, File → Add to Dock, click Add. Standalone window opens. Native traffic lights. No URL bar. No tabs. System notifications attributed to "Swing Deck."
Every dashboard feature works. The 28 confirm-gated buttons fire native macOS dialogs. The 7 window.open sites open in Safari proper. Anchor clicks navigate correctly. Notifications, clipboard, drag-and-drop, scroll behavior, font rendering — all native because Safari IS the WKWebView host, not a wrapper trying to imitate one.
What stayed
The Tauri scaffold lives at tauri-app/ in the repo. Not deleted, not abandoned — shelved. It still builds cleanly. The main.rs is ~50 lines, the Cargo.toml is tiny, the icons are wired. When the right use case shows up — a customer-distribution flow that needs a code-signed installer, or a future dashboard refactor that makes it wrapper-agnostic — the path is there. We just don't run on it today.
The four patches we shipped before the pivot (window.open override, on_navigation handler, tauri-plugin-opener swap, ACL permission grant) all stay in the Tauri code too. They were real fixes, not throwaway. If we restart the Tauri path later, we don't redo them.
The 24/7 brain (separate from the wrapper question)
The other half of "standalone Mac app" was the brain — the Python server running 24/7 instead of only when a Terminal window stayed open. That part shipped independently of the wrapper choice. A macOS LaunchAgent (./packaging/launchd/install.sh) runs control_server.py at every login with auto-respawn. Works identically whether the user opens the dashboard via Safari Add-to-Dock, Chrome Install, a regular browser tab, or the (deferred) Tauri build. The wrapper is the window; the LaunchAgent is the brain. They're orthogonal concerns, and the LaunchAgent path was the same regardless of which wrapper won.
A separate post on the v7.8 release covers the full standalone-app shape including the brain side.
The meta-lesson
"Build once, don't ship the easy thing" is good general advice. It's wrong in this specific case because we misread what "easy" meant. Safari Add-to-Dock isn't easy because it's a shortcut — it's easy because it's structurally appropriate for what we were wrapping. A wrapper that IS a browser doesn't fight browser semantics. A wrapper that imitates a browser does.
Three concrete things I'd do differently next time:
- Inventory the wrapped surface before choosing. Before picking Tauri vs PWA, list every browser API the dashboard uses (alert / confirm / prompt / window.open / Notification / clipboard / drag-drop / etc.). For each, check Tauri / Electron / your-chosen-wrapper's compatibility. A 30-minute survey upfront would have made the wrong choice obvious.
- Treat "build once" as relative, not absolute. "Build once" with Tauri for an app built FOR Tauri is genuinely once. "Build once" with Tauri for a complex browser app is "build, then patch, then patch, then pivot." The phrase "build once" is doing different work in those two sentences.
- Ship the easy thing IF the easy thing is the right thing. The Safari Add-to-Dock path was always the right structural fit. We dismissed it as "the easy path" when it was actually "the path that matched the problem shape." Ease isn't a vice. Mismatch is.
What's on swing-deck.com today
The standalone Mac app shipped via Safari "Add to Dock" + macOS LaunchAgent for 24/7 daemons. Both available now from source clone; the v7.8 public release lands when the GitHub Actions quota refreshes on June 1. The Tauri scaffold ships with the source for anyone who wants to extend it or experiment — just not the official path right now.
Honest scope, every step. What we tried, what worked, what we'll come back to. The customer-facing story is unchanged: the brain runs whether you're watching or not, the window is something you open when you want to look. How we got the window standalone happened to require a course-correction. That's fine. Shipping the right thing matters more than vindicating the original decision.
Claim a Founding Slot — $14.50/mo
Closes out the four-post v7.8 series. Adjacent: v7.8 release post · Three silent broker bugs · Same words, opposite axes · Setup performance.
Disclosure: Swing Deck is built and operated by one person. The product is local-first; positions, broker tokens, and journal entries never leave your machine. AI features use your own API keys (BYOK). We don't proxy your data through our servers. Pricing as of 2026-05-18. Past performance is not indicative of future results. Nothing in this post is investment advice; it's a description of what software does. Tauri and Safari are trademarks of their respective owners; this post is neither endorsed by nor affiliated with either project.