Shipping a macOS and Windows App as a Solo Developer
DropVox ships as a native app on both macOS and Windows. Not Electron. Not a web wrapper. Not a cross-platform framework pretending to be native. Native Swift on macOS. Native C# on Windows. Two completely separate codebases that share zero lines of code.
This sounds like a terrible idea. Here is why it is actually the right one, and how I manage it as a solo developer.
Why Not Cross-Platform?
The obvious question. Electron, Tauri, Flutter, .NET MAUI -- there is no shortage of frameworks that promise "write once, run everywhere." I use Electron for Falavra and TokenCentric. I know the trade-offs well.
For DropVox, cross-platform was the wrong choice for four specific reasons.
Menu Bar Apps Need Deep OS Integration
DropVox lives in the system tray. On macOS, that means NSStatusItem, NSPopover, and native drag-and-drop with NSPasteboardItem. On Windows, that means NotifyIcon in the system tray, XAML flyouts, and COM-based drag-and-drop.
These are not abstraction-friendly APIs. Every cross-platform framework either wraps them poorly or does not support them at all. Electron can put an icon in the tray, but the resulting popup looks and feels like a web page floating above the taskbar. Users notice. Menu bar apps live at the OS level, and they need to feel like it.
// macOS: Native status bar item with SwiftUI popover
let statusItem = NSStatusBar.system.statusItem(
withLength: NSStatusItem.squareLength
)
statusItem.button?.image = NSImage(named: "MenuBarIcon")
let popover = NSPopover()
popover.contentViewController = NSHostingController(
rootView: DropVoxMainView()
)
popover.behavior = .transient
// Windows: WinUI 3 system tray with native flyout
var notifyIcon = new NotifyIcon
{
Icon = new Icon("Assets/TrayIcon.ico"),
Visible = true,
Text = "DropVox"
};
notifyIcon.Click += (s, e) => ShowMainWindow();
Two completely different APIs. Both feel right on their platform. That is the point.
Audio Processing Performance Matters
DropVox transcribes audio using AI models that run locally. On macOS, WhisperKit uses CoreML to run inference on the Neural Engine -- Apple's dedicated ML accelerator. On Windows, Whisper.net (a C# binding for whisper.cpp) uses DirectML for GPU acceleration or falls back to CPU with AVX2/AVX-512 optimizations.
These are platform-specific acceleration paths. A cross-platform framework would force me to use a lowest-common-denominator approach -- likely CPU-only inference through a generic runtime. On an M1 MacBook, that means giving up the Neural Engine, which is the difference between a 10-second transcription and a 45-second one.
For a menu bar utility where speed is the entire UX, that trade-off is unacceptable.
Each Platform Has Its Own Whisper Runtime
The AI models are the same Whisper architecture, but the runtimes are optimized for different hardware:
| macOS | Windows | |
|---|---|---|
| Runtime | WhisperKit | Whisper.net (whisper.cpp) |
| Acceleration | CoreML / Neural Engine | DirectML / CPU (AVX2) |
| Language | Swift | C# |
| Package Manager | SPM | NuGet |
| Model Format | CoreML (.mlmodelc) | GGML (.bin) |
Same model weights, different formats, different inference paths. This is not a problem to abstract away. It is a strength to lean into.
Distribution Requirements Differ
macOS distribution requires Developer ID code signing, hardened runtime entitlements, and notarization through Apple's notary service. Without these, Gatekeeper blocks the app and users see a scary "this app may damage your computer" dialog.
Windows distribution is... less demanding. For now, DropVox ships as an unsigned zip. Windows SmartScreen shows a warning on first run, but users can click through it. Code signing with an EV certificate is on the roadmap, but it costs $300-500 per year and is not strictly required for distribution.
These are fundamentally different distribution pipelines. A cross-platform framework would need to handle both, and in my experience, most handle neither well.
The Monorepo Structure
Both platforms live in the same repository:
dropvox/
├── macos/
│ ├── DropVox.xcodeproj/
│ ├── Sources/
│ │ ├── App/
│ │ ├── Views/
│ │ ├── Models/
│ │ ├── Services/
│ │ └── WhisperKit/
│ ├── Resources/
│ └── Tests/
├── windows/
│ ├── DropVox.sln
│ ├── DropVox/
│ │ ├── App.xaml
│ │ ├── Views/
│ │ ├── Models/
│ │ ├── Services/
│ │ └── WhisperNet/
│ └── Tests/
├── shared/
│ ├── assets/
│ │ ├── icon.png
│ │ ├── icon.ico
│ │ └── logo.svg
│ ├── CHANGELOG.md
│ └── VERSION
├── .github/
│ └── workflows/
│ ├── release-macos.yml
│ └── release-windows.yml
└── README.md
The shared/ directory contains everything that is platform-agnostic: brand assets, the changelog, documentation, and a single VERSION file that both build pipelines read.
Everything else is separate. The macOS app knows nothing about C#. The Windows app knows nothing about Swift. They are neighbors in a monorepo, not coupled components.
CI/CD: One Tag, Two Builds
GitHub Actions handles both platforms. A single git tag triggers parallel workflows.
macOS Release Pipeline
The macOS workflow is the complex one. Apple's distribution requirements add several steps that Windows does not need:
# .github/workflows/release-macos.yml (simplified)
on:
push:
tags: ['v*']
jobs:
build-macos:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Import signing certificate
env:
CERTIFICATE_P12: ${{ secrets.DEVELOPER_ID_CERT }}
CERTIFICATE_PASSWORD: ${{ secrets.CERT_PASSWORD }}
run: |
echo "$CERTIFICATE_P12" | base64 --decode > cert.p12
security import cert.p12 -k build.keychain \
-P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
- name: Build and archive
run: |
xcodebuild archive \
-project macos/DropVox.xcodeproj \
-scheme DropVox \
-archivePath build/DropVox.xcarchive
- name: Export and sign
run: |
xcodebuild -exportArchive \
-archivePath build/DropVox.xcarchive \
-exportPath build/export \
-exportOptionsPlist macos/ExportOptions.plist
- name: Notarize
run: |
xcrun notarytool submit build/export/DropVox.dmg \
--apple-id "${{ secrets.APPLE_ID }}" \
--team-id "${{ secrets.TEAM_ID }}" \
--password "${{ secrets.APP_PASSWORD }}" \
--wait
- name: Staple
run: xcrun stapler staple build/export/DropVox.dmg
- name: Upload release asset
uses: softprops/action-gh-release@v1
with:
files: build/export/DropVox.dmg
Import the Developer ID certificate. Build and archive. Sign with hardened runtime entitlements. Package as a DMG. Submit to Apple's notary service. Wait for approval. Staple the notarization ticket to the DMG. Upload to the GitHub release.
That is seven non-trivial steps after the basic build, each of which can fail for its own reasons. Notarization alone can take anywhere from 30 seconds to 15 minutes depending on Apple's queue.
Windows Release Pipeline
The Windows workflow is refreshingly simple by comparison:
# .github/workflows/release-windows.yml (simplified)
on:
push:
tags: ['v*']
jobs:
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Build
run: |
dotnet publish windows/DropVox/DropVox.csproj \
-c Release \
-r win-x64 \
--self-contained true \
-o build/publish
- name: Package
run: Compress-Archive -Path build/publish/* -Destination build/DropVox-windows.zip
- name: Upload release asset
uses: softprops/action-gh-release@v1
with:
files: build/DropVox-windows.zip
Build. Zip. Upload. Three steps. No certificates, no notarization, no waiting. The difference in complexity is stark.
Auto-Updates
On macOS, DropVox uses the Sparkle framework for auto-updates. Sparkle checks an appcast XML feed for new versions, downloads the update, verifies the code signature, and swaps the binary. It is the standard for non-App Store macOS apps.
On Windows, auto-updates are not yet implemented. Users download the new zip from the GitHub releases page. Adding a proper auto-update mechanism (likely using Squirrel.Windows or a custom solution) is on the roadmap.
What Is Shared
Let me be explicit about what the two platforms share:
- Brand assets. The app icon, logo, and visual identity are in
shared/assets/. Platform-specific formats (.icnsfor macOS,.icofor Windows) are generated from the same source PNG. - Changelog. One CHANGELOG.md that documents every version for both platforms.
- Version number. A single VERSION file read by both CI pipelines. When I bump the version, both platforms get the same number.
- Documentation. The README, feature descriptions, and user-facing docs are platform-agnostic.
What Is NOT Shared
- Zero lines of code. The Swift codebase and the C# codebase have no code in common. Not a utility function. Not a string constant. Nothing.
- No shared data models. Each platform defines its own types for transcriptions, settings, and history.
- No shared UI. Each app uses its platform's native UI toolkit. SwiftUI on macOS. WinUI 3 on Windows.
- No shared build configuration. Xcode and .NET have nothing in common in their build systems.
And this is fine. Better than fine -- it is the reason each app feels right on its platform.
The Honest Trade-Off
Maintaining two codebases is more work. When I add a feature to macOS DropVox, I then need to implement the same feature in C# for Windows. Bug fixes sometimes need to be applied twice. UX decisions need to be re-evaluated for each platform's conventions.
This roughly doubles the implementation time for new features. A feature that takes a day on one platform takes another day to port.
But the result is that users on each platform get a first-class experience. The macOS app feels like a Mac app. The Windows app feels like a Windows app. There are no web-view seams, no non-native scrolling, no "this clearly was not designed for my OS" moments.
For a utility that lives in the system tray and is used dozens of times per day, that native feel is worth the extra engineering time.
Lessons for Other Solo Developers
If you are considering shipping on multiple platforms, here is what I have learned.
Start With One Platform
Do not build both simultaneously. Pick the platform where you are strongest or where your target audience is largest. Build, validate, get users, collect feedback. Then port.
I built DropVox on macOS first because I am a Mac user, Swift is my stronger language for native development, and WhisperKit made the AI integration straightforward. The Windows port came after the macOS version was stable and validated.
Starting with one platform gives you a complete product to learn from. The Windows version benefited from every UX decision, every bug fix, and every user feedback conversation that happened during the macOS development.
Keep Feature Parity Intentional
Not every feature needs to exist on both platforms simultaneously. The macOS version has Sparkle auto-updates. The Windows version does not yet. That is acceptable. Ship what you have, communicate what is coming.
Forced feature parity across platforms slows both down. Let each platform evolve at its own pace, with a shared roadmap that keeps them converging over time.
The Monorepo Is Worth It
Having both platforms in one repository means one set of issues, one set of releases, one changelog. When I tag a release, both platforms build. When a user reports a bug, I can check both implementations in the same PR.
The alternative -- separate repositories -- means managing two sets of issues, two release processes, and two mental models. For a solo developer, the monorepo reduces overhead significantly.
Native Skills Transfer More Than You Think
Learning SwiftUI patterns made me a better WinUI developer, and vice versa. Both platforms use declarative UI, data binding, and reactive state management. The syntax is different. The concepts are similar.
If you know one native platform well, the second one is not a "start from zero" experience. It is more like "same ideas, different vocabulary."
Was It Worth It?
Yes. The users who tell me DropVox "feels like a real Mac app" or "actually works with Windows notification area properly" confirm that the extra effort matters. For a utility app that people interact with throughout their day, native UX is not a luxury. It is the product.
If I were building a content app -- a note-taking tool, a document editor, something where the content is the focus and the chrome is secondary -- I might choose differently. Electron or Tauri could work fine there.
But for a system-level utility that lives in the tray, processes audio through hardware-accelerated AI, and needs to feel invisible? Native is the right call. Even if it means maintaining two codebases as a team of one.
Follow the Journey
I document the build process, the CI/CD setup, and the platform-specific gotchas as I encounter them. If you are shipping native apps as a solo developer, follow along:
- GitHub: github.com/helrabelo
- Twitter/X: twitter.com/helrabelo
- Helsky Labs: helsky-labs.com
Building products at Helsky Labs. Ship fast, learn from metrics, double down on winners.