Building the Mac app
Expo has no macOS target, but it doesn’t need one: the iOS project builds for Mac Catalyst,
which compiles the same app into a real macOS .app (arm64 and x86_64) with resizable windows and
a menu bar. The wide layout — pinned sidebar, list, and task pane side by side — is the same
width >= 900 check the web build uses, so a Mac window gets it with no Mac-specific screens.
cd clientnpm run mac # prebuild with Catalyst on, then a Release build for the Macnpm run mac -- --open # ...and launch itnpm run mac:release # sign, notarize, staple, and package a website downloadnpm run mac:dev # the dev client, built Debug and launched against Metronpm run mac:dev -- --no-build # relaunch the last dev build; enough for JS changesThe development command prints the path to the finished app under client/ios/build/catalyst/.
APP_VARIANT works as
it does for the phone (APP_VARIANT=preview npm run mac), so variants can sit side by side on a
Mac too.
mac:dev is the Mac’s ios:dev plus start:dev in one: it builds the development variant in
Debug, starts Metro in the terminal, and opens the app pointed at it, so JS edits reload in place.
Only a native change needs the rebuild; for anything else, --no-build starts in seconds.
Like ios:dev, this regenerates client/ios with prebuild --clean, and what it leaves there
is a Catalyst-enabled project. The next phone build that prebuilds replaces it; one that doesn’t
(expo run:ios on its own) would compile it as-is, which works but is slower — see below.
What makes it build
Section titled “What makes it build”client/plugins/mac-catalyst applies four changes to the generated project. Each one was a build
failure without it:
| Change | Why |
|---|---|
React Native’s :mac_catalyst_enabled Podfile flag, plus SUPPORTS_MACCATALYST on the app target |
Offers the Mac destination at all, and applies React Native’s Catalyst linker and signing fixes. |
EXPO_USE_PRECOMPILED_MODULES=false |
Expo’s precompiled module xcframeworks have no Catalyst slice, so those modules compile from source instead. React Native’s own prebuilt core and Hermes do have one and stay prebuilt. |
Every pod’s MACOSX_DEPLOYMENT_TARGET → 14.0 |
CocoaPods defaults pods to macOS 10.15, below the oldest macOS Xcode will build for. |
Every pod’s IPHONEOS_DEPLOYMENT_TARGET → 16.4 |
Catalyst derives a pod’s macOS target from its iOS one, and React Native’s pods default to 15.1, which maps too low. The app already requires 16.4, so this changes nothing on a phone. |
It is opt-in — the plugin only runs when MAC_CATALYST=1, which npm run mac sets. Compiling
Expo’s modules from source makes every build slower, and React Native’s Catalyst patch rewrites
the app target’s library search paths; a phone build shouldn’t carry either for a target it isn’t
producing. The plugin’s Podfile edits anchor on text in Expo’s template and fail the prebuild if
it changes, rather than quietly generating a project that won’t build for the Mac.
EAS Build has no Catalyst target, so Mac builds are local-only.
Signed direct-download releases
Section titled “Signed direct-download releases”npm run mac:release creates a Developer ID-signed archive and disk image, submits the DMG to
Apple’s notary service, staples the accepted ticket, verifies it with Gatekeeper, and writes a DMG plus SHA-256
checksum under client/dist/macos/<version>-<build>/. It reads the signing identity from the
login keychain and the notarization credential from the yarukoto-notary keychain profile; neither
secret lives in the repository.
The version defaults to expo.version in app.json. The build number defaults to a UTC timestamp so
successive local releases increase naturally. Either can be made explicit:
cd clientMAC_VERSION=1.0.1 MAC_BUILD_NUMBER=2026092701 npm run mac:releaseSet APPLE_TEAM_ID or NOTARY_PROFILE only when using a different Apple team or keychain profile.
The release prebuild also enables App Sandbox, outgoing network connections, and the hardened runtime
for Catalyst. The existing NSLocalNetworkUsageDescription and NSAllowsLocalNetworking entries
remain responsible for the user-facing LAN prompt and LAN HTTP exception.
Desktop input on the Mac
Section titled “Desktop input on the Mac”On the Mac Platform.OS is 'ios', so nothing keyed on the platform alone can tell it from a
phone. src/data/platform.ts instead answers the questions the UI actually asks:
DESKTOP_UI— a keyboard and pointer are assumed present: the pinned add field instead of the floating button, click-to-select, popovers instead of sheets, resizable panes. Every web build and the Mac.FINE_POINTER— a mouse or trackpad: drags start immediately instead of after a hold, and menus come from right-click instead of long-press.FLOATING_TAB_BAR— the narrow layout’s tab bar is native chrome over the content, which content has to clear. Every native build, the Mac included.
Width decides layout separately, as it always has.
Native menus — the task pane’s Date, Time and Reminders, among others — are SwiftUI menus from
@expo/ui. On the Mac SwiftUI draws a menu’s trigger as a system pop-up button, which can’t show a
custom label, so they rendered as empty pills. src/components/NativeMenu.tsx restyles the trigger
as a plain button on the Mac so our own value-and-chevron label shows, while the menu that opens
stays the system’s. Use it in place of @expo/ui/community/menu.
Light and dark. Native controls — menus, date pickers, glass — take their appearance from the window, which follows the device, while the app has a light/dark setting of its own. On iOS the theme provider overrides the window’s appearance to match the app’s setting, so a dark app on a light Mac or phone gets dark menus instead of light ones.
No wheels. iOS’s wheel pickers aren’t supported on the Mac: UIKit throws the moment one appears,
and the app crashes. The compact time field that stands in for one draws but takes no input when
hosted in the app. So the Mac picks a time from three menus — hour, minute in five-minute steps,
AM/PM (src/components/pickers/MacTimeMenus.tsx) — and a reminder’s offset from a menu, where a
phone spins wheels.
Right-click goes through ContextMenuTarget, which opens the same popover menus on web and the
Mac. On web it listens for the DOM contextmenu event. On iOS, React Native’s gesture recognizers
only accept the primary button, so a right-click never reaches JS on its own. The local Expo module
client/modules/mac-pointer wraps the target in a native view carrying a
UIContextMenuInteraction — the channel Mac Catalyst routes right-clicks through. It reports the
click’s position to JS and declines to show a native menu, so the JS popover opens instead. (A
gesture recognizer requiring the secondary button looks like the obvious tool and doesn’t work:
UIKit never offers it the click.)
The interaction is only installed on the Mac. On a touchscreen it would answer a long press, which already starts a drag, so an iPad with a trackpad has no right-click yet.
Hover tints rows, menu items and buttons as on web. React Native on iOS dispatches no pointer
events at all unless a global switch is on, and it is off by default — so the same module turns it
on, Mac only, from a +load hook that runs before React Native starts (MacPointerEvents.m).
src/components/HoverPressable.tsx then turns pointer enter and leave into the hovered flag
react-native-web already supplies, so the hover styles in src/theme/hover.ts work unchanged. Use it
in place of React Native’s Pressable anywhere that styles on hovered. The list’s drag handle,
which only appears on hover, takes pointer enter and leave directly — and matters more than a tint:
with a fine pointer, rows drag from that handle alone. Tooltips still listen for DOM events and
stay web-only.
The menu bar adds the app’s own commands to the one Catalyst provides, from one list in
src/navigation/commands.ts:
| Menu | Command | Key |
|---|---|---|
| Yarukoto | Settings… | ⌘, |
| File | New Task — the pinned add field, or the Inbox’s on a screen without one | ⌘N |
| Edit | Find… — Browse’s search | ⌘F |
| View | Command Menu… | ⌘K |
| Task | Open Task | ⌘O, or ↩ in the list |
| Task | Mark as Done | ⌘↩ |
| Task | Due Today / Due Tomorrow / Remove Due Date | ⌘T / ⌥⌘T / — |
| Task | Priority ▸ High, Medium, Low, None | ⌥⌘1, 2, 3, 0 |
| Task | Move to Trash | ⌫ in the list |
The Task menu works on the list’s cursor: the row last clicked or arrowed to, which keeps its tint. With a selection it works on all of it. Format is removed, since nothing here styles text. A command nothing on screen can answer is dimmed, and a Task command also while a popover or dialog is up.
The list’s own keys — ↑ and ↓ to move the cursor, ⇧↑ and ⇧↓ to select, ↩ to open, ⌫ to trash,
Escape to deselect — are not in the menu bar, and can’t be: AppKit tries a menu key equivalent before
anything focused hears the key, so a plain ↩ there would take Return from every text field.
Instead the list holds them itself, through KeyCommandsView in client/modules/mac-pointer,
taking the keyboard when it appears, when you click in it, and when a dialog closes. A field
focused anywhere else keeps every key.
The command menu (⌘K) searches every view, list, folder, tag and saved filter, and every command something on screen can answer, with each one’s key beside it — so it doubles as the way to learn them. The web build has it too, along with the Task keys a browser lets a page have (not ⌘N or ⌘T, which open a window and a tab first).
Catalyst asks the app delegate to amend the menu, which no module can do, so plugins/mac-catalyst
writes a small override into the generated AppDelegate that hands the menu to
client/modules/mac-menu. JS sends it the command list once it starts, and screens answer
commands through useCommand in src/navigation/MenuCommands.tsx. Undo doesn’t get a command of
its own: completing or trashing a task registers on the window’s undo manager, so the system’s
Edit ▸ Undo reads “Undo Complete Task” or “Undo Move to Trash” and ⌘Z undoes it for as long as the undo toast is up, while a focused
text field keeps ⌘Z for its typing.
Where it stands
Section titled “Where it stands”The app launches, syncs with a real server, and renders its wide layout. Known gaps:
- Development builds are unsigned.
npm run macstays fast and local. Usenpm run mac:releasefor a Developer ID-signed and notarized website download. - A server on your LAN needs Local Network permission — including a public-looking hostname that split DNS resolves to a private address. macOS drops the connection until Yarukoto is on in System Settings → Privacy & Security → Local Network, and the app shows “Could not reach the server”. Quit and relaunch the app after granting it — the running process keeps failing until then. macOS ties that permission to the code signature, which an unsigned build changes on every rebuild, so the prompt may not appear or may not stick; signing with a stable identity fixes that. Until then, the app’s binary can be added to that list by hand.
- Shift-click and Escape in lists are web-only for now: both listen on
document, which the Mac doesn’t have. ⇧↑ and ⇧↓ select a range from the keyboard instead, and Escape does close popovers and dialogs. - The Pick time sheet is mostly empty on the Mac: three small menus, in a sheet sized for the phone’s wheel.
- Untested on the Mac: notifications, and their Mark done / Snooze actions.
The other route: the iPhone app on Apple Silicon
Section titled “The other route: the iPhone app on Apple Silicon”Separately from Catalyst, Apple Silicon Macs can run the unmodified iPhone build from the Mac App Store — an availability setting on the app record in App Store Connect, not a separate build. It runs in a phone-sized window (the app is iPhone-only; see Building the iOS app) and only on Apple Silicon, but costs nothing. Catalyst is the path to a Mac app that looks and resizes like one.
