Building SimTag: A Deep Dive Through macOS Window APIs, CoreSimulator, DerivedData, and Git
Modern iOS development makes it very easy to lose track of what's actually running in the Simulator.
It’s common to have multiple Claude sessions open at once, a few git worktrees or clones checked out side by side, and several Simulator windows on screen as a natural consequence of juggling multiple work streams.
One window might be running a quick bug fix, another might be testing a refactor, and a third might be a clean build from main. Once you start working that way, the Simulator stops being self-explanatory.
SimTag exists to solve exactly that problem.
SimTag is a macOS menu bar app that figures out which git branch produced the app currently running in each Simulator window, then renders that branch as a small persistent overlay on top of the window.
The goal is simple: remove the guesswork - no more staring at a Simulator and wondering which build you are looking at.
What sounded like a small utility turned into a much deeper problem than I expected.
To answer a seemingly simple question like "what branch is this app from?", SimTag has to answer a chain of smaller questions. This post walks through the implementation step by step, with the real data structures and intermediate outputs along the way.
The Pipeline
Every ~250ms, SimTag runs a pipeline that looks like this:
- Distinguish Simulator windows from all other open macOS windows
- Match Simulator windows to simulator UDIDs (e.g.
simctl) - Find the most recently installed app on each Simulator
- Hash the binary in the Simulator, match it to a
DerivedDatabuild, and recover the project directory - Read the git branch from that project directory
- Detect whether the build is stale
- Position an overlay badge on the Simulator
We'll go through these steps in more detail, but at a high level, data flows through the system like this:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐
│ macOS │ │ simctl │ │ Simulator│ │ DerivedData │
│ Window │ │ (booted │ │ device │ │ + git repo │
│ APIs │ │ devices) │ │ sandbox │ │ │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬──────┘
│ │ │ │
▼ ▼ │ │
Window IDs UDIDs + │ │
+ frames device names │ │
│ │ │ │
└───────┬───────┘ │ │
│ │ │
▼ ▼ │
Match window ──────────► Find app │
to UDID binary │
│ │
▼ ▼
MD5 hash ──────────► Match hash
to project
│
▼
Read branch
+ staleness
│
▼
Position
overlay
Step 1: Finding Simulator Windows
The first question SimTag has to answer is: which Simulator windows are actually on screen right now?
This is where CGWindowListCopyWindowInfo comes in. It's the macOS API that gives you the current on-screen window list, including window IDs, owners, bounds, z-order, and, if permission is granted, titles.
In other words, this is the raw desktop snapshot and includes every visible app window - not just Simulator windows. Those window IDs matter later because they let SimTag track position changes, z-order, and occlusions of windows over time.
This API requires Screen Recording permission to read window titles. Without it, the window names would come back as nil.
let windowList = CGWindowListCopyWindowInfo(
[.optionOnScreenOnly, .excludeDesktopElements],
kCGNullWindowID
) as? [[String: Any]] ?? []
Each entry is a dictionary that looks like this:
─── CGWindowListCopyWindowInfo ───────────────────────────
kCGWindowNumber: 5847
kCGWindowOwnerName: "Simulator"
kCGWindowOwnerPID: 41592
kCGWindowName: "iPhone 16 Pro"
kCGWindowLayer: 0
kCGWindowAlpha: 1.0
kCGWindowBounds: {
X: 306.0
Y: 134.0
Width: 404.0
Height: 883.0
}
──────────────────────────────────────────────────────────
This gives SimTag enough information to do an initial filter on the windowList:
kCGWindowOwnerName == "Simulator"so we only keep Simulator windowskCGWindowLayer == 0so we skip menu bar and HUD-style elementskCGWindowAlpha >= 0.1so we ignore nearly invisible helper windowsWidth > 200 && Height > 200so we ignore accessory panels and tiny utility windows
That sounds straightforward, but it's also where the first real problem appears.
The Missing iOS Version
CGWindowList tells us the window title is "iPhone 16 Pro". That's fine until there are two iPhone 16 Pro simulators open on different runtimes (e.g. iOS 17 vs iOS 18). At that point, the title is no longer specific enough to identify the window.
To disambiguate those windows, SimTag also queries the Accessibility framework through AXUIElement.
This API plays a different role in the lookup chain: it gives richer UI metadata for the same windows, including the full title with the runtime version attached.
This requires Accessibility permission, which is separate from the Screen Recording permission needed earlier for CGWindowList.
let axApp = AXUIElementCreateApplication(simulatorPID)
var windowsRef: CFTypeRef?
AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &windowsRef)
for axWindow in (windowsRef as? [AXUIElement]) ?? [] {
var titleRef: CFTypeRef?
AXUIElementCopyAttributeValue(axWindow, kAXTitleAttribute as CFString, &titleRef)
// titleRef → "iPhone 16 Pro - iOS 18.5"
}
Looking at the two APIs side by side makes the tradeoff clear:
─── CGWindowList ─────────────────────
Title: "iPhone 16 Pro"
Frame: (306, 134, 404, 883)
ID: 5847
─── AXUIElement ──────────────────────
Title: "iPhone 16 Pro - iOS 18.5"
Frame: (306.0, 134.0, 404.0, 883.0)
ID: (not available)
CGWindowList gives us the CGWindowID, which we need later. AXUIElement gives us the fully qualified title, which we also need later. Neither API gives both.
The key observation is that both APIs report the same frame rectangle for the same window. That means the frame can act as a join key:
// From AXUIElement pass: build frame → full title map
var axTitlesByFrame: [String: String] = [:]
for axWindow in axWindows {
var posRef: CFTypeRef?, sizeRef: CFTypeRef?
AXUIElementCopyAttributeValue(axWindow, kAXPositionAttribute as CFString, &posRef)
AXUIElementCopyAttributeValue(axWindow, kAXSizeAttribute as CFString, &sizeRef)
var pos = CGPoint.zero, size = CGSize.zero
AXValueGetValue(posRef as! AXValue, .cgPoint, &pos)
AXValueGetValue(sizeRef as! AXValue, .cgSize, &size)
let frameKey = "\(Int(pos.x)),\(Int(pos.y)),\(Int(size.width)),\(Int(size.height))"
var titleRef: CFTypeRef?
AXUIElementCopyAttributeValue(axWindow, kAXTitleAttribute as CFString, &titleRef)
axTitlesByFrame[frameKey] = titleRef as? String
}
─── axTitlesByFrame ──────────────────────────────────────
"306,134,404,883" → "iPhone 16 Pro - iOS 18.5"
"812,98,1024,1406" → "iPad Pro 13-inch (M4) - iOS 18.5"
──────────────────────────────────────────────────────────
Then, when processing CGWindowList, SimTag looks up the enriched title by frame:
let cgFrame = windowInfo[kCGWindowBounds as String] as! [String: CGFloat]
let frameKey = "\(Int(cgFrame["X"]!)),\(Int(cgFrame["Y"]!)),\(Int(cgFrame["Width"]!)),\(Int(cgFrame["Height"]!))"
let fullTitle = axTitlesByFrame[frameKey] ?? cgTitle
Now the tracking layer has window IDs and full titles in the same structure:
─── trackedWindows ───────────────────────────────────────
[0] windowID: 5847
title: "iPhone 16 Pro - iOS 18.5"
frame: (306, 134, 404, 883)
ownerPID: 41592
isSimulatorFrontmost: true
isDragging: false
isOccluded: false
simulatorUDID: nil ← TODO
[1] windowID: 5902
title: "iPad Pro 13-inch (M4) - iOS 18.5"
frame: (812, 98, 1024, 1406)
ownerPID: 41592 ← same PID, different window
isSimulatorFrontmost: true
isDragging: false
isOccluded: false
simulatorUDID: nil ← TODO
──────────────────────────────────────────────────────────
At this point SimTag knows which Simulator windows exist, but it still needs to map each one to a specific booted simulator device. That device mapping is what lets SimTag move from window metadata to the simulator's filesystem, where the installed app binary can actually be identified.
Step 2: Matching Windows to Simulator UDIDs
So, to reiterate, the next question is: which booted simulator device does each window represent?
This is where simctl becomes the source of truth. simctl is Xcode's command-line interface to CoreSimulator, the subsystem that manages simulator devices, runtimes, and their on-disk data.
Running it through xcrun matters because xcrun resolves the copy of the tool that belongs to the currently selected Xcode installation.
So when SimTag runs xcrun simctl list devices booted -j, what it gets back is the list of currently booted simulator devices, along with each device's UDID, runtime, and sandbox data path.
That matters because the rest of the pipeline is keyed off the UDID, which is the stable identifier for a specific simulator device. Once SimTag knows the UDID for a Simulator window, it can look inside that simulator's filesystem, find installed apps, and start tracing the binary back to the original build.
$ xcrun simctl list devices booted -j
{
"devices": {
"com.apple.CoreSimulator.SimRuntime.iOS-18-5": [
{
"state": "Booted",
"name": "iPhone 16 Pro",
"udid": "B3F4E2A1-7C89-4D56-A123-9E8F7B6C5D4A",
"isAvailable": true,
"dataPath": "/Users/aryaman/Library/Developer/CoreSimulator/Devices/B3F4E2A1-.../data"
},
{
"state": "Booted",
"name": "iPad Pro 13-inch (M4)",
"udid": "F7A1B2C3-D456-E789-F012-3A4B5C6D7E8F",
"isAvailable": true,
"dataPath": "..."
}
]
}
}
The JSON is keyed by runtime identifier, so SimTag parses the version back out:
"com.apple.CoreSimulator.SimRuntime.iOS-18-5"
→ split by "." → last component: "iOS-18-5"
→ split by "-" → ["iOS", "18", "5"]
→ platform = first element: "iOS"
→ version = remaining elements joined by ".": "18.5"
→ result = "iOS 18.5"
That becomes a computed displayIdentifier for each device:
─── simulatorDevices ─────────────────────────────────────
[0] udid: "B3F4E2A1-7C89-4D56-A123-9E8F7B6C5D4A"
name: "iPhone 16 Pro"
runtimeVersion: "iOS 18.5"
displayIdentifier: "iPhone 16 Pro - iOS 18.5"
[1] udid: "F7A1B2C3-D456-E789-F012-3A4B5C6D7E8F"
name: "iPad Pro 13-inch (M4)"
runtimeVersion: "iOS 18.5"
displayIdentifier: "iPad Pro 13-inch (M4) - iOS 18.5"
──────────────────────────────────────────────────────────
Now the matching step becomes a string comparison between the enriched window title and each simulator device's display identifier:
Window: "iPhone 16 Pro - iOS 18.5"
vs
Device[0]: "iPhone 16 Pro - iOS 18.5" ← match → UDID: B3F4E2A1...
Device[1]: "iPad Pro 13-inch (M4) - iOS 18.5" ← no match
The Accessibility API uses an en-dash in the window title, while the simctl-derived identifier uses a plain hyphen. Those strings look almost identical, but == still fails. This subtle bug took longer to find than it should have....
SimTag normalizes both en-dash and em-dash characters to a plain hyphen before matching:
let normalized = title.replacingOccurrences(of: "\u{2013}", with: "-")
.replacingOccurrences(of: "\u{2014}", with: "-")
// "iPhone 16 Pro - iOS 18.5" ✓
After that pass, each TrackedWindow gets its simulatorUDID:
─── trackedWindows (after UDID matching) ─────────────────
[0] windowID: 5847
title: "iPhone 16 Pro - iOS 18.5"
simulatorUDID: "B3F4E2A1-7C89-4D56-A123-9E8F7B6C5D4A" ← matched!
[1] windowID: 5902
title: "iPad Pro 13-inch (M4) - iOS 18.5"
simulatorUDID: "F7A1B2C3-D456-E789-F012-3A4B5C6D7E8F" ← matched!
──────────────────────────────────────────────────────────
At this point, SimTag has connected each visible Simulator window to a specific booted device UDID, which means it now knows both where the window is on screen and which simulator filesystem it belongs to, but it still hasn't identified the app bundle, binary, DerivedData build, or git branch behind it.
Step 3: Finding the Installed App
Once SimTag has the UDID, it can stop reasoning about windows and start reasoning about the simulator's filesystem.
This is another place where a little under-the-hood context helps. Each booted simulator is really just a directory tree on disk managed by CoreSimulator. When you run an app from Xcode, that app bundle gets copied into the simulator's own sandbox. So once SimTag knows which simulator device it is dealing with, it can inspect that sandbox like any other filesystem.
In other words, every simulator device has its own data directory under CoreSimulator - installed app bundles live in a well-known location inside that sandbox:
~/Library/Developer/CoreSimulator/Devices/<UDID>/data/Containers/Bundle/Application/
Inside that directory, each installed app sits in a UUID-named container. Those folder names are just installation containers. They are not the simulator's UDID, and they are not directly useful on their own except as the place where the copied app bundle lives:
─── ls ~/Library/.../B3F4E2A1.../Containers/Bundle/Application/ ──
4A2B8F91-C3D4-5E6F-7890-1A2B3C4D5E6F/
└── MyApp.app/
├── MyApp ← the executable
├── Info.plist
├── Assets.car
└── ...
7B8C9D0E-F1A2-3B4C-5D6E-7F8091A2B3C4/
└── WidgetExtension.appex/
└── ...
SimTag picks the most recently modified .app bundle, which is usually the app the developer most recently built and ran.
From there, it reads CFBundleExecutable out of the app's Info.plist to find the binary name, then hashes the executable with /sbin/md5:
─── Simulator app binary ────────────────────────────────
Path: .../4A2B8F91.../MyApp.app/MyApp
Executable: MyApp
Size: 14.2 MB
Modified: 2026-03-03 14:28:00
MD5: a7f3b2c1d4e5f6a7b8c9d0e1f2a3b4c5
──────────────────────────────────────────────────────────
That hash becomes the fingerprint SimTag uses to search Xcode's build output. At this point, SimTag still doesn't know which project produced the app. It just has a concrete binary fingerprint it can use to look for the matching build artifact in DerivedData.
Why Not Mach-O UUIDs?
My first version used dwarfdump --uuid to read the Mach-O UUID from the binary header. That felt like the obvious solution. Mach-O UUIDs exist specifically to identify a build.
But this broke in exactly the workflow SimTag was meant to support.
With git worktrees, identical source compiled from different working directories can produce binaries with the same Mach-O UUID. The UUID is tied to compilation inputs, not to the fact that the build happened in a different worktree or at a different path.
For SimTag, that is not good enough. If two worktrees can produce binaries that look identical at the UUID level, then the branch lookup becomes ambiguous.
Hashing the entire executable works better here. Even when the source is the same, the actual binary bytes often differ because the compiler embeds build-specific details such as absolute source paths in debug info, __FILE__ references, timestamps, and other metadata. In practice, the full MD5 hash distinguishes builds more reliably than Mach-O UUIDs does.
Step 4: Matching the Hash to DerivedData
At this point SimTag knows which binary is running in the simulator. The next question is what project that binary came from.
This is the job of DerivedData. If you have not spent much time in there, DerivedData is Xcode's scratch space. It stores build products, intermediates, indexes, logs, and metadata for the projects and workspaces you build locally. So if SimTag can find the matching build (same hash) product there, it can work backwards to the original workspace or project directory.
The default search path is ~/Library/Developer/Xcode/DerivedData, but users can also add custom search paths, which matters in worktree-heavy setups or nonstandard Xcode configurations.
This step is easier to follow if you think of it as two separate phases:
- Build an index by scanning
DerivedData, hashing every simulator build product, and remembering which project directory each hash came from. - Take the
MD5from the app currently installed in the Simulator and look it up in that index.
In other words, SimTag is not searching all of DerivedData from scratch every time it needs to identify a running app. It periodically precomputes a lookup table, then uses the live simulator binary hash as the key into that table.
Here is that flow as a sequence:
[Phase 1: Build the index]
DerivedData scanner
-> find candidate DerivedData folders
-> read each folder's info.plist
-> extract WorkspacePath
-> scan Build/Products/*-iphonesimulator
-> hash each executable it finds (MD5)
-> store:
executable hash -> projectDir + buildTime
[Phase 2: Query the index]
Running app in Simulator
-> compute MD5 of installed executable
-> look up that MD5 in the hash index
-> get back:
projectDir + buildTime
The directory walk below is what builds that index. The cache dump after it is what the finished lookup table looks like once that scan is complete.
─── DerivedData scan ─────────────────────────────────────
Search path: ~/Library/Developer/Xcode/DerivedData/
Scanning...
Found: MyApp-abc123def456/
info.plist → WorkspacePath: "/Users/aryaman/Projects/MyApp/MyApp.xcworkspace"
Scanning Build/Products/*-iphonesimulator/...
Debug-iphonesimulator/MyApp.app/MyApp
→ hash: a7f3b2c1d4e5f6a7b8c9d0e1f2a3b4c5
→ buildTime: 2026-03-03 14:28:00
Release Internal-iphonesimulator/MyApp.app/MyApp
→ hash: b8d4e2f1a3c5d6e7f8a9b0c1d2e3f4a5
→ buildTime: 2026-03-02 22:10:00
Found: OtherProject-xyz789/
info.plist → WorkspacePath: "/Users/aryaman/Projects/Other/Other.xcodeproj"
Scanning Build/Products/*-iphonesimulator/...
Debug-iphonesimulator/Other.app/Other
→ hash: e1d2c3b4a5f6e7d8c9b0a1f2e3d4c5b6
→ buildTime: 2026-03-03 09:15:00
Indexed 3 builds from 24 DerivedData folders.
──────────────────────────────────────────────────────────
The resulting cache is a hash-to-project mapping:
─── hashToProjectCache ───────────────────────────────────
"a7f3b2c1d4e5f6a7b8c9d0e1f2a3b4c5"
→ projectDir: "/Users/aryaman/Projects/MyApp"
buildTime: 2026-03-03 14:28:00
"e1d2c3b4a5f6e7d8c9b0a1f2e3d4c5b6"
→ projectDir: "/Users/aryaman/Projects/Other"
buildTime: 2026-03-03 09:15:00
──────────────────────────────────────────────────────────
Now the lookup becomes straightforward. The MD5 hash we computed from the app currently installed in the Simulator matches a cached build product in DerivedData.
Once that match is found, the WorkspacePath in the DerivedData folder's info.plist tells SimTag which .xcworkspace or .xcodeproj produced the binary, and the parent directory of that path becomes the project directory used for the git lookup in the next step.
One detail that turned out to matter here is build configuration names. Xcode doesn't stop at Debug and Release. Plenty of projects use configurations like Release Internal, Staging, or Beta, each of which gets its own directory under Build/Products/.
So SimTag does not hardcode configuration names. It discovers them dynamically by scanning for anything that ends with -iphonesimulator:
let allConfigs = (try? fm.contentsOfDirectory(atPath: buildProductsPath))?
.filter { $0.hasSuffix("-iphonesimulator") } ?? []
for config in allConfigs {
// scan each configuration's .app bundles...
}
This cache is rebuilt every 10 seconds because DerivedData changes continuously while Xcode is building. Periodically refreshing the index lets SimTag discover new simulator binaries and keep the hash lookup accurate without requiring an app restart.
At this point, SimTag has gone from a live app binary back to the project directory that produced it.
Step 5: Reading the Git Branch
At this point, DerivedData has done its job. We no longer need build metadata. We have a real project path on disk, which means the next step is just a git lookup.
In the normal case, this is straightforward. Git stores the current branch reference in .git/HEAD:
─── cat /Users/aryaman/Projects/MyApp/.git/HEAD ──────────
ref: refs/heads/feature/new-onboarding
──────────────────────────────────────────────────────────
Everything after ref: refs/heads/ is the branch name.
The Worktree Wrinkle
If the project is a git worktree, the lookup is a little more involved because the working directory doesn't store git metadata in quite the same way. SimTag has to resolve that indirection first, then read the branch from the correct location.
The important part is that the result is the same: once the repository metadata is resolved, SimTag can still determine the active branch and continue the pipeline normally.
Getting the Commit Hash
To show a short commit hash alongside the branch name, SimTag also reads the ref file itself. This is a separate lookup from reading .git/HEAD.
HEAD tells us which branch is active, while the ref file tells us which commit that branch currently points to.
─── cat .git/refs/heads/feature/new-onboarding ───────────
c9d0e1f2a3b4c5e6d7f8a9b0c1d2e3f4a5b6c7d8
──────────────────────────────────────────────────────────
Short hash: c9d0e1f
Knowing the branch name is progress, but it still leaves some ambiguity: is the app currently on screen actually up to date with that branch?
Step 6: Staleness Detection
Knowing the branch is useful. Knowing whether the build is out of date is often even more useful.
A branch label alone is not enough to tell you whether the app on screen actually reflects the current state of the project. You may have edited code after the last build, or even switched branches after launching the app in the Simulator. In both cases, the branch label may still be technically correct, but it is no longer telling the full story. So SimTag also asks: has the source changed since Xcode last built this app?
The answer comes from comparing three timestamps.
The Three Timestamps
build.db lives under DerivedData at Build/Intermediates.noindex/XCBuildData/build.db. This is Xcode's build system database. It records build graph state, commands, dependencies, and outputs, and Xcode touches it whenever it performs a build, including incremental ones. In practice, this timestamp is a good approximation of "when did Xcode last build this project?"
.git/index is git's staging area. Its modification time changes on a surprising number of working tree operations: git add, git checkout, git merge, git stash, git rebase, and more. It is a coarse signal that something in the working copy changed.
.git/refs/heads/<branch> changes only when the branch tip itself moves, for example after git commit, git merge, git pull, or git cherry-pick.
Those files move at different granularities. build.db tells us when Xcode last built. .git/index tells us the working tree changed somehow. The branch ref tells us whether a commit happened. Comparing all three gives SimTag a more useful answer than any two-file comparison can.
─── Staleness timestamps ─────────────────────────────────
build.db: .../DerivedData/MyApp-abc123/Build/Intermediates.noindex/XCBuildData/build.db
→ modTime: 2026-03-03 14:28:00
.git/index: /Users/aryaman/Projects/MyApp/.git/index
→ modTime: 2026-03-03 14:28:00
refs/heads/: /Users/aryaman/Projects/MyApp/.git/refs/heads/feature/new-onboarding
→ modTime: 2026-03-03 14:25:00
──────────────────────────────────────────────────────────
If the build timestamp is at least as new as the index timestamp, SimTag treats the build as current:
build.db (14:28) >= .git/index (14:28)?
→ YES → .fresh ✅ "Build is up-to-date"
Now watch what happens after you edit a file and stage it:
─── After editing + git add ──────────────────────────────
build.db: 2026-03-03 14:28:00
.git/index: 2026-03-03 14:35:00 ← newer!
refs/heads/: 2026-03-03 14:25:00 ← hasn't moved
build.db (14:28) >= .git/index (14:35)?
→ NO → index changed
refs/heads (14:25) > build.db (14:28)?
→ NO → no new commit
→ .stale ⚠️ "Pending Build"
──────────────────────────────────────────────────────────
And after a commit:
─── After git commit ─────────────────────────────────────
build.db: 2026-03-03 14:28:00
.git/index: 2026-03-03 14:40:00
refs/heads/: 2026-03-03 14:40:00 ← moved forward
build.db (14:28) >= .git/index (14:40)?
→ NO → index changed
refs/heads (14:40) > build.db (14:28)?
→ YES → a commit happened after the build
→ .possiblyStale 🟡 "Build May Be Stale"
──────────────────────────────────────────────────────────
Here is the decision tree as a flowchart:
┌───────────────────────────┐
│ Read 3 timestamps │
│ build.db .git/index │
│ .git/refs/heads/<branch> │
└─────────────┬─────────────┘
│
┌─────────────▼─────────────┐
│ build.db >= index? │
└──────┬─────────────┬──────┘
YES│ │NO
▼ ▼
┌────────────┐ ┌─────────────────┐
│ .fresh │ │ Detached HEAD? │
└────────────┘ └──┬──────────┬───┘
YES│ │NO
▼ ▼
┌────────────────┐ ┌──────────────────┐
│ .possiblyStale │ │ ref > build.db? │
└────────────────┘ └──┬───────────┬───┘
YES│ │NO
▼ ▼
┌────────────────┐ ┌────────────┐
│ .possiblyStale │ │ .stale │
└────────────────┘ └────────────┘
The distinction matters:
.stalemeans code definitely changed after the last build.possiblyStalemeans a commit happened after the build, but that does not guarantee the running target is actually outdated.freshmeans the build is at least as new as the working tree signals SimTag can observe
enum BuildStaleness {
case fresh // build.db >= index
case stale // index > build.db, but no new commit
case possiblyStale // both index and ref moved past build.db
}
Why Three Timestamps Instead of Two?
Early versions of SimTag only compared build.db and .git/index. That worked, but it didn't explain why the index moved.
Did the developer change source and stage it? Or did a commit land, which also updates both the index and the branch ref? Those cases should not produce the same warning.
That difference is mostly about user trust. If SimTag says "Pending Build," that should mean something concrete. The third timestamp makes the warning more honest.
Once SimTag has both pieces, branch identity and build freshness, it has everything it needs for the badge content itself. The last remaining job is purely visual: keep that badge aligned with the correct Simulator window.
Step 7: Positioning the Overlay
By this point, SimTag has solved the data problem. The final problem is UI: how do you make that information feel attached to a moving Simulator window in a way that looks stable and native?
The key detail is that SimTag isn't actually rendering inside Simulator.app. There is no API that lets you embed a SwiftUI view into another app's window. Instead, SimTag creates its own borderless NSWindow, presents it like any normal macOS app would, and continuously repositions that window so it appears visually attached to the matching Simulator window.
CGWindowList and AXUIElement make that possible by giving SimTag the title, frame, and movement of the target window. Once that information is available, the badge itself is just another floating macOS window that SimTag controls:
let overlay = NSWindow(
contentRect: .zero,
styleMask: .borderless,
backing: .buffered,
defer: false
)
overlay.isOpaque = false
overlay.backgroundColor = .clear
overlay.level = .floating + 1
overlay.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
overlay.contentView = NSHostingView(rootView: OverlayBadgeView(...))
The difficult part is making that separate window feel like it belongs to the Simulator window underneath it, which means computing the correct frame every time the target window moves.
CGWindowList reports window frames in CoreGraphics coordinates, where the origin is at the top-left of the primary display and Y increases downward. NSWindow positioning uses AppKit coordinates, where the origin is at the bottom-left and Y increases upward.
That means every overlay placement requires a coordinate transform:
─── Coordinate conversion ────────────────────────────────
Primary screen height: 1080
Simulator window (CG coords):
origin: (306, 134) ← top-left origin
size: (404, 883)
Convert to NS coords:
nsY = screenHeight - cgY - cgHeight
= 1080 - 134 - 883
= 63
Simulator window (NS coords):
origin: (306, 63) ← bottom-left origin
size: (404, 883)
Overlay badge size: (280, 24)
Position: topCenter
overlayX = simX + simWidth/2 - badgeWidth/2
= 306 + 202 - 140
= 368
overlayY = nsSimY + simHeight + margin
= 63 + 883 + 4
= 950
Final overlay frame (NS): (368, 950, 280, 24)
──────────────────────────────────────────────────────────
One critical lesson here: always use NSScreen.screens.first for the screen height, not NSScreen.main.
NSScreen.main follows keyboard focus, so on a multi-monitor setup it changes when you click between displays. If you use that for coordinate conversion, overlays jump to the wrong place. NSScreen.screens.first remains tied to the primary display and stays stable.
Adaptive Polling
The overlay also has to move when the Simulator window moves. That raises a practical question: how often should SimTag poll window state?
Polling at 60fps works, but wastes CPU. Polling once a second keeps CPU low, but makes dragging look bad. The compromise is adaptive polling based on mouse state:
─── Polling state transitions ────────────────────────────
[Normal mode] 4Hz (250ms interval)
└─ Mouse down on Simulator.app window
└─ [Fast mode] 20Hz (50ms interval)
└─ Mouse up + 100ms delay
└─ [Normal mode] 4Hz
──────────────────────────────────────────────────────────
The normal 4Hz loop is not just for drag tracking. It is the background heartbeat that catches everything else that changes without a mouse event:
- the user switches back to Simulator with Cmd+Tab
- another app uncovers or occludes a Simulator window
- a Simulator window closes
- the user changes Spaces and the visible window set changes entirely
SimTag uses NSEvent.addGlobalMonitorForEvents to detect system-wide mouse events and temporarily raise the poll rate while a drag is likely in progress:
NSEvent.addGlobalMonitorForEvents(matching: .leftMouseDown) { [weak self] _ in
let frontmost = NSWorkspace.shared.frontmostApplication?.bundleIdentifier
if frontmost == "com.apple.iphonesimulator" {
self?.startPolling(fast: true) // 20Hz
}
}
NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] _ in
if self?.isInFastPollingMode == true {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self?.startPolling(fast: false) // Back to 4Hz
}
}
}
There is also a cheap signature-based optimization. On each poll, SimTag computes a string signature from:
- Simulator window IDs
- frame rectangles
- z-order indices
- the current frontmost app bundle identifier
If the signature has not changed since the last poll, SimTag skips the expensive overlay update work entirely. In the common case, where Simulator windows are sitting still while you write code, the polling loop is almost free.
Putting It All Together
Once the whole pipeline runs, the system has enough information to produce a final per-window result like this:
─── Pipeline result ──────────────────────────────────────
Window #5847: "iPhone 16 Pro - iOS 18.5"
├── frame: (306, 134, 404, 883)
├── udid: B3F4E2A1-7C89-4D56-A123-9E8F7B6C5D4A
├── app: MyApp.app (hash: a7f3b2c1...)
├── project: /Users/aryaman/Projects/MyApp
├── branch: feature/new-onboarding
├── commit: c9d0e1f
├── buildAge: 2m ago
├── staleness: .fresh ✅
└── overlay: visible @ (368, 950, 280, 24)
──────────────────────────────────────────────────────────
One subtle but important point to reiterate is that none of this ends with SimTag "attaching" a view to Simulator.app. What it actually does is much simpler and much more macOS-native: it presents its own small borderless windows, then uses the polled window metadata to keep those windows aligned with the corresponding Simulator windows as they move around the screen.
In practice, the final handoff looks something like this:
for trackedWindow in trackedWindows {
guard let udid = trackedWindow.simulatorUDID else { continue }
// Hide the badge while the target window is moving or mostly covered.
if trackedWindow.isDragging || trackedWindow.isOccluded {
overlaysByWindowID[trackedWindow.windowID]?.orderOut(nil)
continue
}
let branchInfo = branchDetector.branchInfo(forUDID: udid)
let frame = overlayFrame(for: trackedWindow.frame, badgeSize: badgeSize)
let overlay = overlaysByWindowID[trackedWindow.windowID] ?? makeOverlayWindow()
overlay.contentView = NSHostingView(
rootView: OverlayBadgeView(branchInfo: branchInfo)
)
overlay.setFrame(frame, display: true)
overlay.orderFront(nil)
overlaysByWindowID[trackedWindow.windowID] = overlay
}
That's the full connection point between the earlier stages:
trackedWindowscomes fromCGWindowListandAXUIElementsimulatorUDIDlinks the visible window to a specific booted simulatorbranchInfo(forUDID:)provides the branch, commit, build age, and staleness state derived from the app binary andDerivedDatamatchoverlayFrame(for:)converts the Simulator's frame into the correct position for SimTag's own overlay window
From there, the effect is mostly persistence. SimTag keeps polling, keeps recomputing frames, and keeps moving its own windows so the badges appear to trail the Simulator windows in real time.
The badge shows the branch name, short commit hash, build age, and, when relevant, a staleness warning. You can click to copy the branch name or right-click to add a custom label.
Branch prefixes also get distinct SF Symbol icons and colors:
task/* → number (mint)
feature/* or feat/* → sparkles (blue)
hotfix/*, fix/*, bugfix/*, bug/* → ant.fill (red)
release/* → shippingbox.fill (purple)
main/master → arrow.triangle.branch (green)
develop/dev → arrow.triangle.branch (cyan)
detached HEAD → exclamationmark.triangle.fill (orange)
other → arrow.triangle.branch (white)
Things That Broke Along the Way
A non-exhaustive list of issues that surfaced while building this:
The dash normalization bug. The Accessibility API returns titles with an en-dash, not a plain hyphen. SimTag now normalizes both en-dash and em-dash characters before comparing.
Multi-monitor coordinate conversion. Using NSScreen.main made overlays jump between monitors because .main follows focus.
All Simulator windows share one PID. processIdentifier is useless for distinguishing devices because every window belongs to the single Simulator.app process.
Mach-O UUID collisions across worktrees. That approach looked correct and still failed in the exact workflow the tool was built for.
.git/index changes more often than expected. Checkout, merge, rebase, stash, and add all touch it. The staleness heuristic needed three timestamps to become trustworthy.
Occlusion math. SimTag hides overlays when another window covers more than 70% of the Simulator window, but it has to exclude its own overlay windows and other Simulator windows from that calculation. Otherwise the app would treat the system it is observing as an occluder.
Custom build configurations. Scanning only Debug-iphonesimulator and Release-iphonesimulator missed real builds in configurations like Release Internal. The fix was to discover all *-iphonesimulator directories dynamically.
Space changes. During macOS Space transitions, CGWindowListCopyWindowInfo can briefly return stale data. SimTag listens for NSWorkspace.activeSpaceDidChangeNotification, clears tracked windows immediately, then re-polls after a short delay to avoid ghost badges.
This was one of those projects where each solved problem exposed the next layer underneath it. By the end, something that started as "show me the branch on the Simulator window" had turned into a pipeline across macOS window APIs, Accessibility, CoreSimulator, Xcode build artifacts, and git internals.
Try SimTag
This whole project started from a simple frustration: moving fast with agentic coding and losing track of what was actually running in the Simulator. SimTag turns that into something visible, persistent, and immediate.
If that'd be useful in your workflow, you can get it here.
All future updates are included.