I built the xeve macOS tracker app — a native Swift/SwiftUI menu bar application with app tracking, BLE heart rate monitoring, Sparkle auto-updates, code signing, notarization, and CI/CD — without ever opening Xcode's editor. Every line of Swift was written by Claude Code. I typed prompts in my terminal and watched the app come together file by file.
This is not a toy demo. The app has been running on my machine for a week, tracking every app switch, window title change, and idle period. It syncs to Supabase every 60 seconds. It auto-updates via Sparkle with EdDSA-signed releases. It is notarized by Apple and passes Gatekeeper. Real users are running it.
Here is the complete story — every prompt I used, every issue we hit, and how we fixed them. If you are thinking about building a native app with AI, this is what it actually looks like.
Day 1: From Zero to Running App (March 18, 2026)
The Starting Prompt
The very first thing I did was set up the project structure. I already had the Next.js web dashboard and Supabase backend in place. The macOS app needed to be a native Swift app that lived in the monorepo alongside the web app.
The initial prompt was something like:
Build a native macOS menu bar app in Swift/SwiftUI that:
- Lives in apps/macos/ in the monorepo
- Tracks the active application (app name, bundle ID, window title)
- Detects idle time (5 min threshold)
- Categorizes apps into: Development, Productivity, Communication,
Browsing, Entertainment, Design, Writing, System, Uncategorized
- Syncs tracked sessions to Supabase every 60 seconds
- Uses Google OAuth for auth with the xeve://auth/callback scheme
- Stores auth tokens in macOS Keychain
- Shows today's tracked time in the menu bar
- Uses a monospace, industrial design (dark bg #0f0e12, accent #ff4f00)
Claude Code generated the entire project structure in one pass: XeveApp.swift (the @main entry point with Window + MenuBarExtra scenes), AppTracker.swift (NSWorkspace notifications for app switching + IOKit idle detection), SyncManager.swift (60-second timer with session merging), SupabaseService.swift (auth + data upload), KeychainHelper.swift (implementing Supabase's AuthLocalStorage protocol), and all the view files.
It also generated a project.yml for XcodeGen — this was the critical decision that made the entire "no Xcode editor" workflow possible. XcodeGen generates the .xcodeproj from a YAML file, which means the project configuration is a text file that Claude Code can edit, not a binary Xcode project that requires the IDE.
The First Build
I ran xcodegen generate and then xcodebuild build from the terminal. It failed immediately. The first round of errors was typical Swift project setup issues:
- Missing
import Supabase— the SPM dependency was declared in project.yml but the package resolution had not run - Wrong deployment target — I needed macOS 14.0+ for certain SwiftUI APIs but the default was 13.0
- The Supabase Swift SDK requires specific import names:
import Supabasefor the client,import Authfor auth types
My prompt to fix these was simply:
The build is failing with these errors: [pasted xcodebuild output]
Fix all of them.
This pattern — build, paste errors, fix — became the core loop for the entire project. Claude Code reads the error output, understands the context from the files it has already written, and applies targeted fixes. No manual debugging needed.
Getting App Tracking Working
The app tracking architecture was solid from the first generation. Claude Code knew to use NSWorkspace.shared.notificationCenter for didActivateApplicationNotification instead of polling, which is both more efficient and more reliable. But there were subtle issues:
The tracker is creating a new session every time I switch apps, even when
I switch back to the same app within a few seconds. It should merge
consecutive sessions with the same app if the gap is less than 60 seconds.
Claude Code added session merging to SyncManager.swift — when preparing sessions for upload, it checks if consecutive sessions have the same bundle ID and category, and if the gap between them is under 60 seconds, it merges them into a single longer session. This was a non-obvious data quality requirement that I would not have caught until the dashboard showed fragmented data.
Window Title Polling
The window title tracking required a design decision. NSWorkspace notifications tell you when the active app changes, but they do not tell you when the window title changes within the same app. For a browser, the title changes every time you switch tabs. For an IDE, it changes when you switch files or projects.
Add window title polling every 10 seconds. For browsers (Safari, Chrome,
Arc, Brave, Edge), extract the tab title. For dev apps (Xcode, VS Code,
Cursor, iTerm), extract the project name from the title. Use the
Accessibility API (AXUIElement) to read the focused window title.
This prompt generated the title polling timer and the AXUIElement-based title reading. But the Accessibility API requires explicit user permission, which led to our first macOS permission issue. The app needed the com.apple.security.automation.apple-events entitlement and an NSAppleEventsUsageDescription in Info.plist. Claude Code added both, but the permission prompt only appeared after we added the entitlement to project.yml and regenerated the Xcode project.
Authentication: Google OAuth + Keychain
The auth flow was the trickiest part of Day 1. macOS does not have a built-in OAuth flow like iOS's ASWebAuthenticationSession... actually, it does. Claude Code used ASWebAuthenticationSession, which works on macOS since macOS 12, but with a critical difference: on macOS, you need a PresentationContextProvider that returns an NSWindow for the auth sheet to anchor to.
Auth is working but the browser sheet appears behind other windows.
Fix the presentation context so it appears as a proper modal.
The fix was implementing ASWebAuthenticationPresentationContextProviding with NSApplication.shared.keyWindow as the anchor. Claude Code also correctly set up the URL scheme handling — when Google redirects to xeve://auth/callback#access_token=...&refresh_token=..., the app catches it and sets the Supabase session.
The Keychain integration was elegant. Supabase's Swift SDK has an AuthLocalStorage protocol. Claude Code implemented KeychainHelper conforming to this protocol, so the Supabase client automatically stores and retrieves auth tokens from the macOS Keychain. No UserDefaults, no plain text files. Sessions survive app restarts and system reboots.
The Design System
I had already established the industrial minimalist design language for the web dashboard, so I gave Claude Code very specific instructions:
Use the design system from the root CLAUDE.md. Dark bg #0f0e12,
surface #151517, text #e5e5e5, accent #ff4f00. All fonts must be
.system(.body, design: .monospaced). Cards have 2px border radius max
and 1px borders. No rounded corners beyond 2px. Labels are uppercase
with letter-spacing. Metrics are large monospace with tabular-nums.
Claude Code translated the web design tokens to SwiftUI perfectly. The result is visually consistent across the web dashboard and the native Mac app, which is exactly what I wanted — it should feel like the same product on every platform.
Day 1 Evening: Naming, Sparkle, and Build Scripts
The Rename
The project started as "crnch" (the repo name) but the product name is "xeve". I had already renamed the web app, so I needed the macOS app to match:
Rename the macOS app from crnch to xeve. The source directory should be
apps/macos/xeve/, the app is CrnchApp.swift -> XeveApp.swift, bundle ID
is com.xeve.macos. Delete the old crnch.xcodeproj and crnch/ directory.
Claude Code handled the rename cleanly — moved files, updated all internal references, regenerated the Xcode project. The only issue was that the old .xcodeproj had SPM package resolution cached, and the new one needed a fresh resolve.
Adding Sparkle Auto-Updates
This was one of the most complex prompts because Sparkle has many moving parts:
Add Sparkle auto-updates to the macOS app. Use Sparkle 2.6+ with EdDSA
signing. The appcast.xml should be hosted on Supabase Storage at
downloads/appcast.xml. Add an UpdaterManager service that checks for
updates hourly. Add SUFeedURL and SUPublicEDKey to Info.plist.
Also create build scripts:
1. build-and-release.sh — archive, sign, create zip, generate EdDSA
signature, create appcast.xml
2. upload-release.sh — upload zip and appcast.xml to Supabase Storage
3. generate-sparkle-keys.sh — generate EdDSA key pair
Claude Code generated all three scripts and the UpdaterManager.swift service. The Sparkle integration required adding the SPM package to project.yml, creating the SPUStandardUpdaterController in the app delegate, and wiring up a "Check for Updates" menu item.
The EdDSA key generation script was straightforward — Sparkle includes a generate_keys tool in its package. But the tricky part was getting the public key into Info.plist correctly and having the build script call Sparkle's sign_update tool to sign the zip file.
Adding BLE Heart Rate
Add BLE heart rate monitoring. Scan for devices with the standard Heart
Rate Service (UUID 180D). Parse the Heart Rate Measurement characteristic
(UUID 2A37). Auto-connect to the first discovered device or a saved
preferred device. Log heart rate to Supabase heart_rate_logs every
30 seconds. Show current HR in the menu bar next to the time total.
Claude Code generated HeartRateMonitor.swift with CoreBluetooth scanning, connection management, and characteristic parsing. The BLE heart rate standard is well-documented, and Claude Code knew the exact byte format: first byte is flags, second byte (or second+third if 16-bit flag is set) is the BPM value.
The main issue was Bluetooth permissions. We needed NSBluetoothAlwaysUsageDescription in Info.plist and the Bluetooth background mode in project.yml. Claude Code added both, but the permission prompt only appeared after a clean build.
Day 2: CI/CD — The Hardest Part (March 19)
GitHub Actions: Attempt 1
Getting the app to build locally was the easy part. Getting it to build in CI, with code signing, notarization, and Sparkle signing, was a multi-hour battle.
Create a GitHub Actions workflow at .github/workflows/macos-release.yml
that triggers on tags matching macos-v*. It should:
1. Check out the repo
2. Install xcodegen
3. Generate the Xcode project
4. Build with xcodebuild (Release configuration)
5. Sign with Developer ID Application certificate
6. Notarize with Apple
7. Generate Sparkle EdDSA signature
8. Create appcast.xml
9. Upload zip + appcast.xml to Supabase Storage
10. Create a GitHub Release
The first workflow Claude Code generated was structurally correct but failed on almost every step that involved secrets or external services.
Issue 1: Sparkle Signing in CI
The sign_update tool from Sparkle expects the EdDSA private key either from stdin or from a file. In CI, the key is a GitHub Secret. The first approach tried piping the key via echo:
echo "$SPARKLE_ED_KEY" | ./sign_update xeve.zip
This failed silently — the tool did not read from stdin correctly in the CI environment. The fix was writing the key to a temp file and using the -f flag:
The Sparkle signing step is failing silently. The sign_update tool
isn't reading the key from stdin in CI. Try writing the key to a temp
file and passing it with -f flag instead.
Claude Code updated the workflow to write the secret to a temp file, pass -f /tmp/sparkle_key, and clean up the file afterward.
Issue 2: Supabase Storage Upload
The initial approach tried using the Supabase CLI to upload files to Storage. This failed because the CLI's storage commands were not available in the version installed by CI.
The Supabase CLI upload is failing. Switch to using the REST API
directly with curl instead of the CLI.
Claude Code rewrote the upload step to use curl with the Supabase Storage REST API. But it used the anon key, which does not have write access to Storage. Next attempt used an access token, which also failed because access tokens are for the management API, not the data API.
The upload is still failing with 403. The anon key can't write to
storage and the access token is for the management API. Use the
service role key instead.
Third time was the charm. The service role key has full access to Storage. Claude Code added SUPABASE_SERVICE_ROLE_KEY as a required secret and updated the curl commands.
Issue 3: Code Signing + Notarization
This was the biggest CI battle. Code signing in GitHub Actions requires importing a Developer ID certificate from a .p12 file stored as a base64-encoded secret.
Add code signing and notarization to the CI workflow. The certificate is
stored as a base64-encoded GitHub Secret (APPLE_CERTIFICATE_P12). Import
it into a temporary keychain, sign with Developer ID Application, submit
for notarization with xcrun notarytool, and staple the ticket.
Claude Code generated the keychain import steps correctly, but notarization had its own set of issues:
- Hardened runtime required — Apple rejects notarization if the app is not signed with
--options runtime. Claude Code knew this, but the first version only signed the top-level .app. Sparkle's embedded XPC service and framework also needed to be signed with hardened runtime. - Deep signing — The
--deepflag oncodesignis supposed to handle nested bundles, but it does not always work reliably. We ended up signing Sparkle's components individually before signing the main app. - Working directory — The notarization step used
cdto change into the build directory, but in GitHub Actions, eachrun:step starts in the workspace root. Thecdwas lost. Fix: useworking-directory:in the step config instead ofcd.
The notarize step is failing because cd doesn't persist between run
commands in GitHub Actions. Use working-directory instead.
Issue 4: The appcast.xml Duplicate Attribute
After fixing signing and notarization, the Sparkle update check was failing on client machines. The appcast.xml had a duplicate length attribute in the <enclosure> tag — once from the RSS spec and once from Sparkle's namespace. XML parsers choked on it.
Sparkle is failing to parse the appcast.xml. Check the XML for
validity issues.
Claude Code found the duplicate attribute and removed the redundant one. A small bug, but it caused the entire auto-update system to silently fail.
Issue 5: EdDSA Key in Info.plist
Sparkle verifies updates against a public key embedded in the app's Info.plist as SUPublicEDKey. The key was set correctly in project.yml, but xcodegen was not copying it into the built Info.plist. The key was being treated as a build setting variable $(SPARKLE_ED_PUBLIC_KEY) rather than a literal string.
The Sparkle public key is not being resolved in Info.plist. It shows
$(SPARKLE_ED_PUBLIC_KEY) literally instead of the actual key. Set it as
a literal string value, not a build setting reference.
Claude Code changed the project.yml to embed the key directly as a string in the Info.plist definition rather than referencing a build setting. This was a subtle XcodeGen behavior — build settings in plist values are resolved by Xcode at build time, but only if they are defined in the correct scope.
Day 2-3: Feature Explosion
Once the CI pipeline was working, adding features became fast. Each feature was a single prompt that Claude Code would implement, I would build from the terminal, test, and push. Here are the major ones:
Browser Tab Tracking
Add browser tab counting via AppleScript. Count open tabs in Chrome,
Safari, Brave, Edge, and Arc every 60 seconds. Store snapshots in a
tab_snapshots table. Show total tabs in the menu bar. The app needs
the Automation entitlement for AppleScript.
This required the NSAppleEventsUsageDescription usage string in Info.plist for the Automation permission prompt. The first version worked but the permission prompt never appeared — because the entitlement was missing from the project.yml's entitlements file. Claude Code added it to both the Info.plist and the entitlements.
Now Playing / Media Tracking
Add a NowPlayingTracker that detects what media is playing. Use IOKit
(pmset -g assertions) to find apps with audio/video assertions. For
browsers, use AppleScript to check for YouTube, Netflix, Twitch URLs.
For native apps, detect Spotify, Apple Music, Netflix, Apple TV+. Log
watching sessions to app_sessions with category Entertainment.
This was one of the more creative implementations. pmset -g assertions lists all power assertions, including "PreventUserIdleDisplaySleep" which apps claim when playing media. By parsing this output, the tracker can detect which apps are playing audio or video without needing any special APIs.
Pomodoro Timer
Add a Pomodoro timer to the menu bar. 25 min work, 5 min break,
15 min long break after 4 cycles. Show the timer text (MM:SS) in the
menu bar when active. Send a notification when each session completes.
Log completed pomodoros as app_sessions with bundle com.xeve.focus.
The Pomodoro timer replaced the menu bar icon with a countdown when active. The timer text updates every second using a Timer.scheduledTimer with a 1-second interval. When the timer finishes, a UserNotification is sent, and the session is logged to Supabase as a tracked app session.
App Blocklist
Add the ability to block apps from being synced. Blocked apps should
still be tracked locally (for total time calculations) but never
uploaded to Supabase. Store blocked bundle IDs in UserDefaults. Add a
BlockedAppsView to the windowed app showing all tracked apps with
toggles to block/unblock.
Incognito Mode
Add an Incognito toggle to the menu bar. When enabled, tracking
continues locally but no data is synced to Supabase. Show a visual
indicator (an eye icon) in the menu bar when Incognito is active.
Persist the state in UserDefaults.
AI Category Classification
Add AI-powered category classification for unknown apps. When the
CategoryResolver can't determine the category from the hardcoded
defaults or inference rules, call OpenRouter with Claude Haiku to
classify the app. Cache the result locally and sync it to the
category_mappings table in Supabase.
This was interesting because Claude Code wrote code that calls Claude (via OpenRouter) from within the Mac app. The AI classification is async — the app assigns "Uncategorized" immediately, then fires an AI request in the background. When the response comes back, the category is updated and cached for future sessions.
The Three Silent Failures (Day 3-4)
On Day 3, I tagged macos-v0.4.1, v0.4.2, and v0.4.3 across several hours. Each tag triggered the CI workflow. Each workflow failed. I did not notice because I was not watching CI — I was adding features.
The failure was a Swift compiler error in NowPlayingTracker.swift. The Supabase Swift SDK had updated, and .eq() now required a value: argument label. The code compiled locally because my SPM cache had the old SDK. CI resolved fresh and got the new version.
Three CI builds have been silently failing. The error is in
NowPlayingTracker.swift — the Supabase SDK updated and .eq() now
requires a value: label. Fix it and also add the value: label to
every .eq() call across the entire macOS codebase.
Claude Code fixed all instances across all files. But the real lesson was: pin your SPM dependencies to exact versions. We were using from: "2.0.0" which resolves to the latest compatible version. A minor version bump with a label change was enough to break the build.
The loginwindow Ghost (Day 4)
After fixing the CI builds, I noticed the dashboard showed 10+ hours of screen time per day, with "loginwindow" as the #1 app at 8 hours. That is macOS's lock screen.
The tracker already filtered out loginwindow in the handleAppSwitch() function — but old sessions from before the filter existed were already in the database. When the app fetched today's historical data on startup, those old sessions came right back.
loginwindow is showing up in the dashboard with 8+ hours. The tracker
already filters it from new sessions but old data is in the database.
Add filters to:
1. The Supabase fetch query (exclude loginwindow, ScreenSaverEngine,
UserNotificationCenter)
2. The live session aggregation
3. The menu bar top apps display
4. Every web dashboard query that reads app_sessions
This was a "defense in depth" fix. The write path was already guarded, but every read path also needed the filter. Claude Code added .not('bundle_id', 'in', '("com.apple.loginwindow","com.apple.ScreenSaverEngine","com.apple.UserNotificationCenter")') to every relevant query in both the macOS app and the web dashboard.
What Made This Work
XcodeGen Was Essential
The entire workflow depends on XcodeGen. Without it, the Xcode project file is a binary blob that requires the IDE to modify. With XcodeGen, the project is a YAML file that Claude Code can edit like any other text file. Adding a new file, changing a build setting, adding a capability — it is all a YAML edit followed by xcodegen generate.
The Build-Error Feedback Loop
The core development loop was:
- Write a prompt describing what I want
- Claude Code generates/modifies Swift files
- Run
xcodebuild buildfrom terminal - If it fails, paste the errors back to Claude Code
- Repeat until it compiles
- Run the app, test the feature
- If behavior is wrong, describe what is wrong and let Claude Code fix it
This loop is fast. Most features compiled on the first or second attempt. Build errors in Swift are descriptive enough that Claude Code can fix them without additional context.
CLAUDE.md As Living Documentation
I created a CLAUDE.md file in the macOS app directory that documents every architectural pattern: the AppState singleton, the service lifecycle pattern, the SwiftUI conventions, the Supabase integration patterns. Claude Code reads this file at the start of every conversation, so it always knows the codebase's conventions.
This is like having an onboarding doc that the AI actually reads. When I ask for a new service, Claude Code follows the same ObservableObject pattern with start()/stop() lifecycle, @Published properties, and [weak self] in closures — because the CLAUDE.md says to.
What I Would Do Differently
- Pin SPM dependencies from day one. The silent CI failures cost a week of auto-updates.
- Add CI failure notifications. A Slack webhook on workflow failure would have caught the broken builds immediately.
- Test notarization locally first. We wasted several CI runs debugging notarization issues that we could have caught with a local
xcrun notarytool submit. - Write the CLAUDE.md earlier. The first few features had inconsistent patterns. After writing the CLAUDE.md, every new feature matched the established architecture.
The Numbers
From initial prompt to production app with auto-updates: 2 days.
- ~30 Swift source files across Models, Views, Services, and Tracker directories
- 90+ hardcoded app-to-category mappings in CategoryResolver
- 7 background services (AppTracker, SyncManager, HeartRateMonitor, NowPlayingTracker, TabCounter, NotificationTracker, PomodoroManager)
- 4 build scripts (build-and-release, upload-release, notarize, generate-sparkle-keys)
- 1 GitHub Actions workflow with 10+ steps
- 14 version bumps in 4 days (0.1.0 to 0.4.4)
- 0 lines of Swift written by hand in Xcode
For Other Claude Code Users
If you want to build a native macOS app with Claude Code, here is what I would recommend:
- Use XcodeGen. Do not try to work with .xcodeproj files directly. They are not designed for text-based editing.
- Write a CLAUDE.md. Document your design system, architecture patterns, and conventions. Claude Code will follow them consistently.
- Build from the terminal.
xcodebuild -scheme YourApp -configuration Debug buildgives you the same output as Xcode but in a format that Claude Code can parse. - Paste build errors verbatim. Do not summarize or truncate. The full error output gives Claude Code the context to fix the issue in one shot.
- One feature per prompt. Describe what you want, let it build, test it, then move on. Do not batch multiple features into one prompt — the error surface becomes too large.
- Use the Supabase Swift SDK. It handles auth token refresh, Keychain storage, and typed queries. Claude Code knows its API well.
- Test on a real device. Bluetooth, Accessibility, and Automation permissions only work on real hardware. The Simulator cannot test these.
- Set up CI early. The gap between "it builds locally" and "it builds in CI" is larger than you think. Code signing, notarization, and SPM resolution all behave differently in GitHub Actions.
The macOS app is open source and running at xeve.io. Every line of Swift in it was generated by Claude Code from natural language prompts typed into a terminal.