rea-reverse

How to Reverse Engineer a Mac App: Rectangle Step by Step

Written for REA 6.7.0Published by rea-reverseUpdated 6 min read
On this page

To reverse engineer a Mac app, start with the bundle, not the disassembler. Read Info.plist, the Mach-O slices and load commands, the code signature, and the compiled NIBs. Together they tell you how the app is assembled and which class handles each menu item. You only need a decompiler for the last step, and by then you know which function to open.

Everything below was run on Rectangle 2.0.4, the MIT-licensed window manager (Rectangle2.0.4.zip from its GitHub release, SHA-256 0b9b8b11…aedc9). The host was macOS 27.0.1 with Xcode 27.0, Node 22.22.3 and REA 6.7.0, a third-party open-source tool (npm install -g rea-agents) that wraps Apple’s command-line tools and returns JSON. The app was never launched. Stick to apps whose license allows analysis, such as open-source apps or your own builds.

curl -LO https://github.com/rxhanson/Rectangle/releases/download/v2.0.4/Rectangle2.0.4.zip
ditto -x -k Rectangle2.0.4.zip .
APP="$PWD/Rectangle.app"     # REA wants absolute paths
Reverse Engineer a macOS App with REA: Mach-O, Swift and NIBsSilent walkthrough with English captions.

What needs a Mac

REA command Uses Off macOS
inspect-artifact, project-apple-application-graph, trace-dylib-resolution, decode-interface-builder, inspect-keyed-archive Pure file parsing Works (DMG contents need macOS)
inspect-plist, list-architectures, inspect-macho, inspect-signature, demangle-swift plutil, lipo, otool, nm, dyld_info, codesign, swift-demangle via xcrun unsupported_host
inspect-native-dispatch-metadata Built-in Mach-O byte reader Symbol-only, through Ghidra
inspect-asset-catalog assetutil No
search, decompile, trace-native-ui-action Ghidra, Hopper or IDA Depends on the engine

1. Inventory and bundle layout

rea inspect-artifact "$APP" --json > inspection.json
jq '{inventory_evidence: [.normalized_result.substeps[].evidence]}' inspection.json > apple-input.json
rea project-apple-application-graph ./apple-input.json --json > apple-graph.json
jq -c '.normalized_result.bundles[] | {path, role}' apple-graph.json

The inventory took about a second. It recorded 261 nodes (274 occurrences), each with a SHA-256. The projection then grouped them into bundles:

{"path":".","role":"application"}
{"path":"Contents/Frameworks/Sparkle.framework","role":"framework"}
{"path":"Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app","role":"nested-application"}
{"path":"Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Downloader.xpc","role":"xpc-service"}
{"path":"Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Installer.xpc","role":"xpc-service"}
{"path":"Contents/Resources/MASShortcut_MASShortcut.bundle","role":"resource-bundle"}

So Rectangle is one Swift executable plus the Sparkle updater, with its own app and two XPC services, and a resource bundle from the MASShortcut library. runtime_families was ["native"]. An Electron app would show Electron Framework.framework and an app.asar here, and you would switch to the Electron guide. Roles come from path conventions only. The next steps check them against the actual files.

2. Info.plist

rea inspect-plist "$APP" --json | jq '.normalized_result.bundle'
plutil -p "$APP/Contents/Info.plist"     # same data, no REA
{"identifier":"com.knollsoft.Rectangle","executable":"Rectangle","name":"Rectangle","version":"111","short_version":"2.0.4"}

The full decoded value has 30 keys. The useful ones here: LSUIElement: true (a menu-bar app with no Dock icon), LSMinimumSystemVersion: "14.0", a rectangle:// URL scheme in CFBundleURLTypes, NSMainStoryboardFile, and the Sparkle feed SUFeedURL: https://rectangleapp.com/downloads/updates.xml. Nested bundles take --relative-path. For Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Installer.xpc/Contents/Info.plist it returned org.sparkle-project.InstallerLauncher, version 2.10.0.

3. Mach-O slices, build and dependencies

rea list-architectures "$APP" --json | jq -c '.normalized_result.architectures.items[] | {name, file_offset, size}'
{"name":"x86_64","file_offset":16384,"size":2255536}
{"name":"arm64","file_offset":2277376,"size":1965840}

It is a universal binary, 4,243,216 bytes in total. To check slices on any file without REA, see the universal binary guide. inspect-macho read the arm64 slice by default (selected_architecture: {"name":"arm64","container_file_offset":2277376}). It reported UUID 9B853EC6-717D-3C6C-893B-6B10D844116A, platform MACOS with minimum OS 14.0 and SDK 27.0, and 33 dependencies. The first dependency was @rpath/Sparkle.framework/Versions/B/Sparkle, followed by Foundation, AppKit, SwiftUI, Combine, Carbon, ServiceManagement and 19 /usr/lib/swift/libswift*.dylib entries.

Don’t hand the whole result to an agent. REA’s own --token-count flag measured it:

rea inspect-macho "$APP" --token-count                    # 30232 (default TOON format)
rea inspect-macho "$APP" --format json --token-count      # 53147
rea inspect-macho "$APP" --filter-output normalized_result.dependencies --token-count   # 1165

The JSON was 173,662 bytes, and 143,470 of those were the 1,235 imports. imports.total came back null with exhaustive: false, because dyld_info can miss chained-fixup metadata. Treat the import list as a lower bound. Upstream issue #2473 tracks the size problem. Use --filter-output or jq to select the part you need.

4. Code signature and entitlements

rea inspect-signature "$APP" --json | jq '.normalized_result | {identifier, team_identifier, authorities, hardened_runtime, entitlements}'
codesign -dv "$APP"; codesign -d --entitlements - "$APP"
{
  "identifier": "com.knollsoft.Rectangle",
  "team_identifier": "XSYZ3E4B7D",
  "authorities": ["Developer ID Application: Ryan Hanson (XSYZ3E4B7D)", "Developer ID Certification Authority", "Apple Root CA"],
  "hardened_runtime": true,
  "entitlements": {}
}

codesign agreed: flags=0x10000(runtime), timestamp Oct 21, 2026, sealed resources covering 96 files, and an empty [Dict] for entitlements. That empty dictionary is a finding. Rectangle is not sandboxed and declares nothing special. The Accessibility access it needs to move windows is granted by the user in System Settings (TCC). It is not declared as an entitlement, so you won’t find it in the signature. The binary confirms it: its imports include _AXIsProcessTrusted, _AXObserverCreate and _AXUIElementCopyAttributeValue.

5. How dyld will find Sparkle

rea trace-dylib-resolution "$APP" --json > dylibs.json
jq -c '[.normalized_result.edges[].resolution.status] | group_by(.) | map({(.[0]): length}) | add' dylibs.json
{"conditional":2,"undetermined":150}

REA traced 10 roots (5 executables × 2 slices) and 152 load edges, with no findings. The 150 undetermined edges are system libraries outside the bundle, which live in the dyld shared cache. They are not missing. The two conditional edges are the interesting part, the main executable’s Sparkle load on each slice:

install_name: @rpath/Sparkle.framework/Versions/B/Sparkle
  1. /usr/lib/swift/Sparkle.framework/Versions/B/Sparkle   rpath /usr/lib/swift                 outside-target
  2. Contents/MacOS/../Frameworks/Sparkle.framework/...     rpath @executable_path/../Frameworks resolved

otool -l shows why. The executable carries two LC_RPATH entries in this order, /usr/lib/swift and then @executable_path/../Frameworks. dyld tries the first, which normally doesn’t exist, and then loads the bundled copy. “Conditional” means a file at the earlier location would win. For the Library not loaded: @rpath/... error and its fix, see tracing macOS dylib resolution.

6. From a menu item to its handler class

rea decode-interface-builder "$APP" --json > ib.json
jq -c '.normalized_result.documents[] | {relative_path, document_kind, object_count, connection_count}' ib.json

REA decoded 7 archives in under a second: Main.storyboardc/MainMenu.nib (128 objects, 42 connections), three LogViewer.storyboardc scenes, MessageView.nib, and two Sparkle nibs. The graph held 570 nodes and 287 edges, of which 126 were target-action connections and 45 were outlets. Two documents were partial with connection_unresolved, meaning a connector had no resolvable owner. REA lists those as omissions and does not guess them.

Most MainMenu actions go to the first responder (copy:, undo:, performFindPanelAction:). Five have an explicit destination, object 229:

ignoreFrontMostApp:  openPreferences:  showAbout:  viewLogging:  checkForUpdates:   -> object 229
object 229 class_name: _TtC9Rectangle11AppDelegate
rea demangle-swift "$APP" '_TtC9Rectangle11AppDelegate' '$s7SwiftUI4ViewMp' --json
_TtC9Rectangle11AppDelegate -> Rectangle.AppDelegate                    demangled
$s7SwiftUI4ViewMp           -> protocol descriptor for SwiftUI.View      demangled

A target-action entry only says what Interface Builder wired up. It doesn’t prove the method exists, so check that next.

7. Classes, methods and addresses

rea inspect-native-dispatch-metadata "$APP" --json > dispatch.json
jq -r '.normalized_result.result.objc_classes[] | select(.name=="_TtC9Rectangle11AppDelegate" and .is_meta_class==false)
  | .methods[] | select(.selector|test("openPreferences|applicationDidFinishLaunching")) | "\(.selector) \(.address)"' dispatch.json

The output was 3.28 MB. It covered 214 Objective-C-visible classes (202 of them in the Rectangle module), 184 Swift conformances and 534 Swift vtable slots. Rectangle.AppDelegate has 36 methods, 23 ivars and an instance size of 192. The two selectors resolved to applicationDidFinishLaunching: at 0x1000d0010 and openPreferences: at 0x1000d1754. Coverage was partial. A few Swift ivar offsets didn’t resolve, and the record cap was reached. Generic and resilient Swift tables are reported as unsupported, never guessed.

You can check an address without a decompiler:

otool -arch arm64 -tV "$APP/Contents/MacOS/Rectangle" | grep -A16 '^00000001000d1754'
00000001000d1754	sub	sp, sp, #0x40
...
00000001000d1770	bl	0x100136ed8 ; symbol stub for: _swift_unknownObjectRetain
00000001000d1788	bl	0x100136110 ; symbol stub for: _$ss018_bridgeAnyObjectToB0yypyXlSgF
00000001000d1790	bl	0x100136ecc ; symbol stub for: _swift_unknownObjectRelease
00000001000d1794	bl	0x1000d16d8

That is the usual shape of an @objc thunk. It retains the sender, bridges it to Swift Any, and calls the real Swift body at 0x1000d16d8. That second address is the one to open in a decompiler.

8. Keyed archives and asset catalogs

Rectangle ships no NSKeyedArchiver files. Its .nib files are the compiled NIBArchive format, and REA says so instead of misparsing them:

rea inspect-keyed-archive "$APP" Contents/Resources/MessageView.nib --json
unsupported_target: Selected file is a compiled NIBArchive, not a Foundation plist archive; decode it with decode_interface_builder

To show what a real keyed archive looks like, we archived a small NSMutableDictionary with Swift’s NSKeyedArchiver (413 bytes). It held a name, an actions array that referenced one string twice, and a peer key that pointed back to the dictionary:

rea inspect-keyed-archive "$PWD/demo.archive" --json | jq -c '.normalized_result.objects[] | {id, kind, class_name, value}'
{"id":1,"kind":"archived-object","class_name":"NSMutableDictionary","value":{"NS.keys":[{"UID":2},{"UID":3},{"UID":4}],"NS.objects":[{"UID":5},{"UID":6},{"UID":1}],"$class":{"UID":9}}}
{"id":6,"kind":"archived-object","class_name":"NSArray","value":{"NS.objects":[{"UID":7},{"UID":7}],"$class":{"UID":8}}}
{"id":7,"kind":"scalar","class_name":null,"value":"tile-left"}

There were 10 objects and 11 references. A UID is an index into objects. Object 1’s third value is UID 1, itself, so a naive recursive walk will never end. Track visited IDs. Paging keeps the original indices. --offset 6 --limit 2 returned IDs 6 and 7 with next_offset: 8. The last page (--offset 8 --limit 2) returned next_offset: null but still truncated: true, so loop on next_offset, not truncated. UID 0 is $null, and REA notes that a conditional reference encoded as UID 0 looks identical to it.

rea inspect-asset-catalog "$APP" --limit 200 --json read Assets.car through assetutil. It found 154 records, mostly AppIcon_Assets renditions, along with the authoring tool string (Xcode 27.0 (27A266a)). It reads metadata only and does not extract images.

9. Decompiling the handler

Function-level work needs an engine. On our machine none was configured, and REA refused instead of guessing:

rea search "$APP" applicationDidFinishLaunching --json
provider_unavailable … ghidra: not_configured — "Set GHIDRA_INSTALL_DIR to an extracted Ghidra 12.1.x release directory."

trace-native-ui-action "$APP" 'openPreferences:' failed the same way. With Ghidra 12.1.x installed (see the Ghidra guide), decompile the Swift body found in step 7, 0x1000d16d8. Use --procedure= for Objective-C names like -[...], because otherwise the leading - is parsed as a flag. trace-native-ui-action then joins steps 6 and 7. It follows an IB action to its handler and the handler’s static callees, but it doesn’t claim the path ever runs. Observing a real run (observe-native-calls, under LLDB) launches the app. A hardened-runtime build like this one also needs get-task-allow, so do that only on your own builds.