Skip to content

Add macOS (AppKit) support: a headless view-tree walker#740

Draft
markmals wants to merge 7 commits into
FLEXTool:masterfrom
markmals:macos
Draft

Add macOS (AppKit) support: a headless view-tree walker#740
markmals wants to merge 7 commits into
FLEXTool:masterfrom
markmals:macos

Conversation

@markmals

@markmals markmals commented Jun 6, 2026

Copy link
Copy Markdown

Summary

Adds macOS (AppKit) support to FLEX as a new, self-contained FLEXAppKit product: a headless NSView view-tree walker that produces an immutable, JSON-serializable snapshot of a running app's real view hierarchy — actual runtime class names, frames, fonts, CALayer facts, Auto Layout constraints, NSVisualEffectView materials, and the windows. It's the macOS analog of the existing FHSView/FHSViewSnapshot, built for headless/programmatic use.

Packaged as a separate FLEXAppKit SPM target/product under Classes/ViewHierarchy/AppKit/, excluded from the iOS FLEX target. The iOS build is untouched — the only change to the existing target is one added exclude entry for a brand-new directory.

What's in it

A walker (FLEXAppKitWalker) → immutable snapshots (FLEXAppKit{ViewSnapshot,WindowSnapshot,Font,Layer,Color}, FLEXConstraintNode) capturing per node:

  • Real runtime class (object_getClass) + the superclass chain.
  • Geometry across the AppKit coordinate flip — raw frame and a normalized top-left, full-window-frame-relative rect and isFlipped, computed via screen-coordinate conversion (so per-view isFlipped is handled by AppKit, not manual y-flipping — the Fixed some warnings #1 silent-correctness trap of an AppKit port).
  • FontsNSFont decomposed to family/size/PostScript/traits + the raw NSFontWeightTrait and the nearest named weight (never a lossy NSFontManager 1–14 conversion).
  • Layers — facts where the view is layer-backed (nil layer is a normal case, not an error) + the parallel CALayer subtree (layer.sublayers ≠ subviews, standalone sublayers included), depth-capped.
  • Colors — appearance-resolved sRGB hex + catalog name for live NSColor; baked sRGB hex for CGColor (layer colors are flattened, so the catalog identity is gone — handled honestly).
  • Constraints — every NSLayoutConstraint touching a node in both directions + intrinsic-sizing facts (cross-platform, zero #if).
  • material/blendingMode, axRole, identifier, text, hidden/alpha, swiftUIBoundary (flagged at NSHostingView), --depth/truncated, hitTest: at a point, and NSApp.windows enumeration with sheets/child windows nested under their parent.

FLEXAppKitJSON projects the snapshot into JSON-serializable Foundation dictionaries (fixed precision, deterministic).

Verify

swift run FLEXAppKitProbe     # 47 in-process correctness checks (synthetic view trees)
swift run SampleAppKitDump    # builds a representative window, prints its own tree as JSON

Note: FLEX's single FLEX target owns all of Classes/, so swift test tries to compile the UIKit code on macOS. Until an engine split, the scoped swift run harnesses above are the test path.

For your review — scope & follow-ups

  • This is the AppKit walker only. It deliberately does not include a FLEXCore/FLEX engine product split — so macOS ivars/props/classes (the reflection engine) are a natural follow-up PR. Happy to do that next if you'd like.
  • Branch target: should this land on master, or would you prefer a macos feature branch?
  • DevProbe / SampleAppKitDump / FLEXAppKitJSON are verification tooling + a JSON convenience — glad to drop or relocate them if you'd prefer a library-only PR.
  • All new AppKit code is #if TARGET_OS_OSX-guarded.

markmals added 7 commits June 5, 2026 16:59
FLEXAppKitWalker is the macOS analog of FHSView: it walks NSView subtrees
into an immutable FLEXAppKitViewSnapshot per node -- real runtime class via
object_getClass, raw frame plus a normalized top-left rect with isFlipped,
hidden/alpha/identifier, and a decomposed NSFont (FLEXAppKitFont: raw
CoreText weight trait + nearest named weight, never a lossy NSFontManager
conversion).

Frames normalize against the full window frame via screen conversion, so
per-view isFlipped is resolved by AppKit rather than manual y-flipping --
the silent-correctness trap of an AppKit port.

Shipped as a separate FLEXAppKit SPM target/product under
Classes/ViewHierarchy/AppKit, excluded from the iOS FLEX target, so the iOS
build is unaffected and the FLEXCore engine split can land later. DevProbe
is a scoped correctness harness: `swift run FLEXAppKitProbe` (13 checks).

Package.swift was reindented by tooling; upstream style restored before PR.
Build out FLEXAppKitWalker to the genuinely-AppKit-specific field set the
objc runtime cannot compute on its own:

- NSApp.windows enumeration as tree roots (key/main/panel + contentView
  subtree) -- the rooted entry the flat heap enumerator can't provide.
- swiftUIBoundary flag at NSHostingView (substring match across the class
  chain, since NSHostingView<Content> has a mangled Swift name); traversal
  continues through the layer-backed scaffold below.
- FLEXAppKitColor: appearance-resolved sRGB hex (#RRGGBBAA) + catalog name
  where the color is a catalog color + the appearance context. Resolves
  under performAsCurrentDrawingAppearance so catalog/dynamic colors do not
  return nil or throw.
- FLEXAppKitLayer: layer facts where the view is layer-backed, plus the
  PARALLEL CALayer subtree (sublayers incl. standalone ones backing no
  view). A nil layer stays nil -- a normal case, not an error.
- NSVisualEffectView material / blendingMode.

Snapshots refactored to a property-set pattern (internal headers) so the
immutable record can grow without an unwieldy initializer.

DevProbe extended to 26 checks (all pass), incl. the coordinate flip, the
layer/color decomposition (#FF0000FF round-trip), and the nil-layer case
verified against a standalone view (views inside a modern window are
implicitly layer-backed).
Add the remaining domain.node fields the walker produces: superclasses
(class chain to NSObject), axRole (accessibilityRole), text (NSControl /
NSText displayed string -- what the selector grammar's `text` predicate
matches), and constraintsCount (forward constraints on the view; the full
both-directions list is reserved for the constraints verb).

Add depth-bounded traversal: snapshotForView:inWindow:maxDepth: and
snapshotApplicationWindowsWithMaxDepth:. A node at the bound with subviews
reports truncated == YES + childCount and omits children; a leaf is never
truncated. childCount is always emitted.

DevProbe extended (all checks pass).
Add FLEXConstraintNode: NSLayoutConstraints touching a view in BOTH
directions (the view's own constraints + ancestor-held constraints that
reference it -- AppKit has no public reverse index), each serialized as
first.attr (relation) second.attr * multiplier + constant @ priority with
per-item class/kind/isTarget, plus intrinsic-sizing facts
(translatesAutoresizingMaskIntoConstraints, per-axis hugging/compression,
intrinsicContentSize). Cross-platform -- NSLayoutConstraint is one class.

Fold in the adversarial review's four confirmed findings:
- color: a CGColor (every layer color) is flattened, so appearance
  resolution and catalog-name capture are impossible on it. Split the path
  so a CGColor yields baked sRGB hex only, while live NSColor inputs get
  appearance-resolved hex + catalog name. Header contract corrected.
- layer: bound CALayer recursion (cap 64) with truncated + sublayerCount,
  so pathological deep trees (CATiledLayer/WebKit/Metal) cannot overflow
  the stack.
- windows: only top-level windows are roots; attached sheets and child
  windows nest under their parent (a visited set guards cycles) -- the
  nested-transient contract, previously emitted flat.

DevProbe now 46 checks (all pass).
snapshotForHitTestAtPoint:inWindow: returns the deepest view at a window-
base point as a single node (children omitted) -- the macOS substitute for
touch hit-testing (HANDOFF 5.2). DevProbe now 47 checks, all pass.
FLEXAppKitJSON projects the walker's snapshot model into JSON-serializable
Foundation dictionaries shaped per the node schema (fixed-precision floats,
NSNull for nils, deterministic key order), per ARCHITECTURE 5.3 -- the FLEX
side returns Foundation collections and the consumer serializes the string.

SampleAppKitDump builds a representative window (sidebar NSVisualEffectView,
known fonts, a layer-backed accent row, Auto Layout) and prints its own
runtime view tree as JSON via the real walker -- a manual-verification
artifact needing no injection or SIP changes: `swift run SampleAppKitDump`.

Also fix FLEXConstraintNode: the forward pass now keeps only constraints
that TOUCH the view (first or second item == it), excluding constraints a
view merely holds between its descendants -- matching the spec's both-
directions = touching, which drops AppKit's private internal label
constraints from the result.
Scrub flexscope-internal references so the contribution stands alone in
FLEX: remove the `// SPEC: domain.walker` reverse-pointers (a flexscope
spec-traceability convention) from all FLEXAppKit sources, reword the few
prose mentions of domain.walker / the node schema / ARCHITECTURE into
self-contained descriptions, and restore Package.swift to FLEX's upstream
4-space style so the diff is just the added FLEXAppKit product/targets.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant