macOS Setup — Build & Run
This guide walks through getting a Vue Native app building and running as a native macOS desktop application. vue-native create scaffolds a complete, working macOS host, so the happy path is three commands.
Prerequisites
| Tool | Version | Install |
|---|---|---|
| Xcode | 16+ | Mac App Store |
| Xcode Command Line Tools | (bundled) | xcode-select --install |
| XcodeGen | 2.40+ | brew install xcodegen |
| Bun | 1.3+ | curl -fsSL https://bun.sh/install | bash |
XcodeGen is required, not optional: macos/project.yml is the committed source of truth and the generated macos/*.xcodeproj is gitignored, so vue-native run macos and vue-native build macos call xcodegen generate whenever the project is missing. Without it the CLI stops with XcodeGen is required to generate macos/project.yml.
Verify your environment:
xcode-select -p # /Applications/Xcode.app/Contents/Developer
swift --version # Apple Swift version 6.0+
xcodegen --version # Version: 2.4x.x
bun --version # 1.3+
vue-native doctor checks all of these for you, including the macos/ host.
First-time Xcode install
After installing Xcode, open it once and accept the license.
Quick start
# 1. Scaffold the shared Vue app plus the iOS, Android and macOS hosts
npx @thelacanians/vue-native-cli create my-mac-app
cd my-mac-app
bun install
# 2. Build the JS bundle, generate the Xcode project, build and launch
vue-native run macos
That is the whole flow. There is no Xcode project to create by hand, no Package.swift to write, no Info.plist to fill in and no bundle resource to drag into a target — create generates all of it and run macos wires it together.
vue-native run macos does the following:
- Runs
vite buildwithVUE_NATIVE_PLATFORM=macosto producedist/vue-native-bundle.js - Calls
ensureXcodeProject(macos/), which runsxcodegen generateif no.xcodeprojexists yet - Builds with
xcodebuild -destination 'platform=macOS' build - Locates the product in Derived Data and launches it with
open
The generated host is ad-hoc signed (CODE_SIGN_IDENTITY: "-"), so it builds and runs with no Apple Developer team configured. Change CODE_SIGN_STYLE to Automatic and set DEVELOPMENT_TEAM in macos/project.yml before distributing the app off your machine.
What create generates
my-mac-app/
app/ # Vue 3 source (shared by all three platforms)
main.ts
App.vue
pages/Home.vue
macos/ # macOS host
project.yml # XcodeGen spec — the committed source of truth
Sources/
main.swift # Programmatic NSApplication entry point
AppDelegate.swift # Subclasses VueNativeAppDelegate (installs the main menu)
MainWindowController.swift# Subclasses VueNativeWindowController
Info.plist
App.entitlements
MyMacApp.xcodeproj/ # Generated by XcodeGen — gitignored
ios/ # iOS host (project.yml + Sources/)
android/ # Android host (Gradle)
native/ # Vendored Vue Native runtime, copied from the CLI
macos/VueNativeMacOS/ # The Swift package your host links against
shared/VueNativeShared/ # Cross-platform Swift, a local path dependency of the above
ios/VueNativeCore/
android/VueNativeCore/
dist/ # Built JS bundle (generated)
vue-native.config.ts # Includes a macos { deploymentTarget } section
vite.config.ts
package.json
Only macos/project.yml and macos/Sources/ are committed. macos/*.xcodeproj/ and macos/*.xcworkspace/ are in the generated .gitignore, exactly like their ios/ counterparts — regenerate them with xcodegen generate (or just run vue-native run macos).
The generated host
Every file below is ordinary source you own. Edit it freely; vue-native run macos keeps using whatever is in macos/Sources/.
main.swift — programmatic entry point
import AppKit
// Retained for the process lifetime: NSApplication.delegate is weak, and
// top-level bindings in main.swift live as long as the executable does.
let delegate = AppDelegate()
let app = NSApplication.shared
app.delegate = delegate
// Nothing reads an activation policy out of a nib here, so state it: the app
// owns a Dock item and should come to the front when `vue-native run macos`
// launches it with `open`.
app.setActivationPolicy(.regular)
app.activate(ignoringOtherApps: true)
app.run()
There is no storyboard and no .xib, so @main on the delegate and NSApplicationMain would have nothing to load. The activation policy is set explicitly for the same reason.
AppDelegate.swift — the main menu
import AppKit
import VueNativeMacOS
class AppDelegate: VueNativeAppDelegate {
override func createWindowController() -> VueNativeWindowController {
MainWindowController()
}
}
Subclass VueNativeAppDelegate rather than implementing NSApplicationDelegate yourself: it installs a main menu at launch. A programmatically launched NSApplication otherwise gets an empty menu bar, and AppKit routes key equivalents through the main menu — so without it Cmd+Q does not quit and Cmd+C/V/X/A never reach a focused VInput. Override makeMainMenu() to replace or extend the standard App / Edit / View / Window / Help menu; useMenu() merges into whatever is installed.
MainWindowController.swift — where the Vue app renders
import Foundation
import VueNativeMacOS
final class MainWindowController: VueNativeWindowController {
/// Matches the resource copied in by macos/project.yml
/// (../dist/vue-native-bundle.js -> Contents/Resources/vue-native-bundle.js).
override var bundleName: String { "vue-native-bundle" }
#if DEBUG
/// `vue-native dev --platform macos` serves the rebuilt bundle here.
/// A production build skips the connection entirely.
override var devServerURL: URL? { URL(string: "ws://localhost:8174") }
#endif
}
Info.plist
Ships live values, not commented-out placeholders: NSPrincipalClass = NSApplication, LSMinimumSystemVersion = 15.0, NSHighResolutionCapable, NSAllowsLocalNetworking (for the hot-reload socket), and the three usage descriptions the macOS framework can actually reach — camera, microphone and location. Contacts, Calendar, Bluetooth and Photos have no macOS code path in the framework, and LocalAuthentication and UNUserNotificationCenter need no Info.plist string on macOS.
App.entitlements
Sets com.apple.security.app-sandbox to false. That is deliberate: useFileSystem() and useDatabase() read and write per-app folders under ~/Library (see useFileSystem), the dev host opens a ws://localhost:8174 socket, and useCamera() / useGeolocation() reach device hardware. Under the sandbox each of those needs its own entitlement (network.client, files.user-selected.read-write, device.camera) or the call fails at runtime. Sandboxing is a distribution decision, so the scaffold states the non-sandboxed default in one editable place. If you enable the sandbox, add the entitlements your app actually uses.
project.yml
The XcodeGen spec. It points the target at the vendored package, copies the Vite output into Contents/Resources, sets INFOPLIST_FILE / CODE_SIGN_ENTITLEMENTS, declares an explicit (shared) scheme so xcodebuild -scheme works on a fresh clone, and pins ad-hoc signing:
packages:
VueNativeMacOS:
path: ../native/macos/VueNativeMacOS
targets:
MyMacApp:
type: application
platform: macOS
sources:
- path: Sources
excludes:
- Info.plist
- App.entitlements
- path: ../dist/vue-native-bundle.js
buildPhase: resources
optional: true
scheme: {}
dependencies:
- package: VueNativeMacOS
product: VueNativeMacOS
Info.plist and App.entitlements are excluded from sources because they are consumed through build settings; leaving them in would make two build steps produce the same file.
Consuming VueNativeMacOS
The only supported path is the vendored native/ tree inside a scaffolded project, referenced from project.yml as a local path: package.
Do not add VueNativeMacOS as a remote git dependency:
# Does NOT work — do not do this
packages:
VueNativeMacOS:
url: https://github.com/abdul-hamid-achik/vue-native
branch: main
native/macos/VueNativeMacOS/Package.swift declares VueNativeShared as a local dependency (path: ../../shared/VueNativeShared). SPM rejects local path: dependencies inside a package it resolves from a remote URL, so resolution fails before it ever compiles. A relative path only resolves inside a native/{macos,shared} tree on disk — which is exactly what vue-native create copies into your project.
To pick up a newer runtime, re-scaffold and copy native/ across, or update the vendored tree in place. native/.vue-native-version records which CLI version vendored it.
One window per app
The generated host is single-window by design. NativeBridge.shared and JSRuntime.shared are process-wide singletons, so MainWindowController is the app's one and only Vue surface: a second VueNativeWindowController would tear the first one's component registry down rather than render a second app. Multi-window Vue Native apps are not supported.
If you need more chrome, put it in the Vue tree (VToolbar, VSplitView, VOutlineView, VModal) or in the main menu via makeMainMenu() / useMenu().
Hot reload
Hot reload lets you edit .vue files and see changes instantly without rebuilding the native app.
Setup
Terminal 1 — Start the dev server:
vue-native dev --platform macos
This starts:
- Vite in watch mode (rebuilds
dist/vue-native-bundle.json every save) - A WebSocket server on
ws://localhost:8174
Terminal 2 — Run the app (first time only):
vue-native run macos
Once the app is running you do not need to rebuild — changes are pushed over the WebSocket. The scaffolded MainWindowController already returns ws://localhost:8174 from devServerURL inside #if DEBUG, and a production build skips the connection entirely. Because the app runs on the same machine as the dev server, localhost works directly; NSAllowsLocalNetworking in the generated Info.plist is what permits the socket.
How it works
- You save
app/pages/Home.vue - Vite detects the change and rebuilds
vue-native-bundle.js - The dev server sends the new bundle over WebSocket
HotReloadManagerin the app receives itJSRuntimetears down the old JSContext, creates a new one, evaluates the new bundle- The window content updates on screen
Debugging
Xcode console
All console.log() / console.error() calls from your Vue code appear in the Xcode console prefixed with [VueNative JS]:
[VueNative JS] App started
[VueNative JS] Navigated to /settings
Bridge operations are logged as:
[VueNative] createComponent VView (nodeId: 3)
[VueNative] setProp text="Hello" (nodeId: 5)
Error overlay
In debug builds, unhandled JS errors display a red overlay in the window with the error message and stack trace.
Checking the bundle loaded
If you see an empty window, verify the bundle is executing:
// Add to the top of app/main.ts
console.log('Bundle executing')
If you see the log, the bundle loaded. If not, check:
dist/vue-native-bundle.jsexists (bun run buildorvue-native run macoscompleted without errors)macos/project.ymlstill lists../dist/vue-native-bundle.jswithbuildPhase: resources- The file landed in the product:
ls <path-to>.app/Contents/Resources/vue-native-bundle.js
Common issues
"XcodeGen is required to generate macos/project.yml" — brew install xcodegen, then retry.
"No macos/ directory found" — the project was created before the macOS host existed. Re-scaffold with vue-native create into a scratch directory and copy macos/ and native/ across, then run vue-native run macos.
"No such module 'VueNativeMacOS'" — the package has not resolved yet. Regenerate the project (cd macos && xcodegen generate) and build once from Xcode so SPM resolves native/macos/VueNativeMacOS and its local native/shared/VueNativeShared dependency. If that fails, delete DerivedData:
rm -rf ~/Library/Developer/Xcode/DerivedData
SPM resolution fails after switching to a git URL dependency — expected. VueNativeMacOS depends on VueNativeShared by local path, which SPM rejects inside a remotely resolved package. Use the vendored path: dependency described above.
"Signing requires a development team" — the scaffold uses ad-hoc signing, so this only appears after you switch CODE_SIGN_STYLE to Automatic. Either set DEVELOPMENT_TEAM in macos/project.yml or revert to CODE_SIGN_STYLE: Manual with CODE_SIGN_IDENTITY: "-".
Empty window — the most likely cause is the JS bundle missing from Contents/Resources. Rebuild with bun run build, then rebuild the native app. Also check the Xcode console for [VueNative] errors.
Cmd+C / Cmd+V / Cmd+Q do nothing — AppDelegate must subclass VueNativeAppDelegate, which installs the main menu. Implementing NSApplicationDelegate directly leaves the menu bar empty and AppKit has nowhere to route key equivalents.
A second window shows the error overlay or a blank view — multi-window is unsupported; see One window per app.
Coordinate system issues — AppKit uses a bottom-left origin by default. VueNativeMacOS uses FlippedView (an NSView subclass with isFlipped = true) to provide a top-left origin consistent with iOS and CSS. If you add custom NSView subclasses, override isFlipped to return true.
See the Troubleshooting guide for more.