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:

  1. Distinguish Simulator windows from all other open macOS windows
  2. Match Simulator windows to simulator UDIDs (e.g. simctl)
  3. Find the most recently installed app on each Simulator
  4. Hash the binary in the Simulator, match it to a DerivedData build, and recover the project directory
  5. Read the git branch from that project directory
  6. Detect whether the build is stale
  7. 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 windows
  • kCGWindowLayer == 0 so we skip menu bar and HUD-style elements
  • kCGWindowAlpha >= 0.1 so we ignore nearly invisible helper windows
  • Width > 200 && Height > 200 so 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:

  1. Build an index by scanning DerivedData, hashing every simulator build product, and remembering which project directory each hash came from.
  2. Take the MD5 from 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:

  • .stale means code definitely changed after the last build
  • .possiblyStale means a commit happened after the build, but that does not guarantee the running target is actually outdated
  • .fresh means 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:

  • trackedWindows comes from CGWindowList and AXUIElement
  • simulatorUDID links the visible window to a specific booted simulator
  • branchInfo(forUDID:) provides the branch, commit, build age, and staleness state derived from the app binary and DerivedData match
  • overlayFrame(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.

SimTag: Context for your iOS Simulators
See which git branch your iOS Simulator is actually running.Even before AI coding, it was common to have multiple copies of the same project open—using git worktrees or separate clones—to work on different branches in parallel.Now, with multiple Claude Code (or other AI) sessions running at once, each working on a different branch, it’s even easier to lose track of what’s running where.The result: multiple simulators, all looking identical, with no clear indication of which branch they’re running.SimTag fixes that.What SimTag DoesSimTag adds a small overlay to each iOS Simulator window indicating which git branch produced the running build.That’s it.Easily keep track of which branch the iOS Simulator is runningAt a glance, you know exactly what you’re looking at—no more guessing, no more double-checking, no more debugging the wrong build.Why It’s UsefulWhen you glance at a simulator, you immediately know: Did I rebuild after switching branches? Which simulator has the auth changes? Am I debugging my own work or a coworker’s branch? If you do any kind of parallel development—worktrees, PR reviews, or AI-assisted coding—this removes a constant source of confusion.SimTag is especially useful if you: Use git worktrees or multiple clones Run several terminal sessions building to different simulators Review PRs while keeping your own work running But even with a simpler workflow, SimTag still helps: Quick confirmation that the simulator matches the branch you think you’re on Pending Build indicator warns you when commits exist since the last build PR review sanity check so you know you’re testing the right code The overlay is unobtrusive and easy to ignore—until you need it.FeaturesBranch overlaySee the git branch for every simulator window at a glance.Pending build indicatorA small warning dot appears when commits exist since the last build—no more debugging code that isn’t even running.Custom labelsAdd your own text like “PR Review”, “Testing”, or “Spike” to keep simulators clearly identified.Getting Started Download SimTag Move the application to the Applications folder. Launch and grant Accessibility permissions (System Settings → Privacy &amp; Security → Screen &amp; System Audio Recording). Grant file access permissions. Details macOS 13+ Runs in the menu bar Optional launch at login Overlay position is configurable (any corner, top/bottom center) Requires Screen Recording permission(Used only to track window positions—not to record anything. Setup is guided on first launch.) FAQMultiple Xcode projects open?SimTag figures out which project produced each simulator build.React Native / Flutter?Works fine—SimTag detects the git branch of the Xcode project that built the app.Git worktrees?Fully supported. Each worktree shows its own branch correctly.I use SimTag every day now. It’s a small tool, but it removes a surprisingly persistent source of friction.Questions or feedback? Message me at aryaman@digitalbunker.dev.

All future updates are included.