From 38e4bb6b67e7c68532b7e7f2fbac44bb699fd7f6 Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Fri, 5 Jun 2026 16:59:39 -0500 Subject: [PATCH 1/7] feat: add headless AppKit view-tree walker (macOS) 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. --- Classes/ViewHierarchy/AppKit/FLEXAppKitFont.h | 34 +++ Classes/ViewHierarchy/AppKit/FLEXAppKitFont.m | 115 ++++++++++ .../AppKit/FLEXAppKitViewSnapshot.h | 57 +++++ .../AppKit/FLEXAppKitViewSnapshot.m | 36 +++ .../ViewHierarchy/AppKit/FLEXAppKitWalker.h | 32 +++ .../ViewHierarchy/AppKit/FLEXAppKitWalker.m | 59 +++++ DevProbe/main.m | 100 +++++++++ Package.swift | 205 ++++++++++-------- 8 files changed, 544 insertions(+), 94 deletions(-) create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitFont.h create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitFont.m create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m create mode 100644 DevProbe/main.m diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.h new file mode 100644 index 0000000000..0bf4f31bc2 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.h @@ -0,0 +1,34 @@ +// +// FLEXAppKitFont.h +// FLEX +// +// Decomposed NSFont facts read off a font carrier. Emits the raw CoreText +// weight trait AND the nearest named weight — never a lossy NSFontManager +// (1–14) conversion. +// +// SPEC: domain.walker +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitFont : NSObject + +/// Decompose the font carried by `object` (or its `-cell`), or nil if it carries none. ++ (nullable FLEXAppKitFont *)fontForObject:(id)object; + +@property (nonatomic, readonly, copy) NSString *familyName; +@property (nonatomic, readonly) double pointSize; +/// Raw CoreText NSFontWeightTrait, in [-1.0, 1.0]. 0.0 when the descriptor omits it. +@property (nonatomic, readonly) double weightTrait; +/// Nearest named weight ("regular", "semibold", …) to `weightTrait`. +@property (nonatomic, readonly, copy) NSString *weightName; +/// PostScript name (e.g. ".SFNS-Regular"), or nil if unavailable. +@property (nonatomic, readonly, copy, nullable) NSString *postScriptName; +/// Symbolic traits present on the font ("bold", "italic", "monoSpace", …). +@property (nonatomic, readonly, copy) NSArray *traits; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.m new file mode 100644 index 0000000000..42c090546c --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.m @@ -0,0 +1,115 @@ +// +// FLEXAppKitFont.m +// FLEX +// +// SPEC: domain.walker +// + +#import "FLEXAppKitFont.h" + +#if TARGET_OS_OSX + +#import + +@interface FLEXAppKitFont () +@property (nonatomic, copy) NSString *familyName; +@property (nonatomic) double pointSize; +@property (nonatomic) double weightTrait; +@property (nonatomic, copy) NSString *weightName; +@property (nonatomic, copy, nullable) NSString *postScriptName; +@property (nonatomic, copy) NSArray *traits; +@end + +/// The font, read off `object` directly or off its `-cell`, or nil. The carrier set is +/// "any object responding to -font" per domain.walker — not a hardcoded class list. +static NSFont *FLEXFontFromCarrier(id object) { + if (object == nil) { + return nil; + } + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + if ([object respondsToSelector:@selector(font)]) { + id font = [object performSelector:@selector(font)]; + if ([font isKindOfClass:[NSFont class]]) { + return font; + } + } + if ([object respondsToSelector:@selector(cell)]) { + id cell = [object performSelector:@selector(cell)]; + if ([cell respondsToSelector:@selector(font)]) { + id font = [cell performSelector:@selector(font)]; + if ([font isKindOfClass:[NSFont class]]) { + return font; + } + } + } +#pragma clang diagnostic pop + + return nil; +} + +/// Nearest named weight to a raw CoreText trait, using AppKit's own constants so the +/// thresholds track the platform rather than hardcoded folklore numbers. +static NSString *FLEXNearestWeightName(CGFloat weight) { + const struct { CGFloat value; NSString *name; } weights[] = { + { NSFontWeightUltraLight, @"ultraLight" }, + { NSFontWeightThin, @"thin" }, + { NSFontWeightLight, @"light" }, + { NSFontWeightRegular, @"regular" }, + { NSFontWeightMedium, @"medium" }, + { NSFontWeightSemibold, @"semibold" }, + { NSFontWeightBold, @"bold" }, + { NSFontWeightHeavy, @"heavy" }, + { NSFontWeightBlack, @"black" }, + }; + + NSString *nearest = @"regular"; + CGFloat bestDelta = CGFLOAT_MAX; + for (size_t i = 0; i < sizeof(weights) / sizeof(weights[0]); i++) { + CGFloat delta = ABS(weight - weights[i].value); + if (delta < bestDelta) { + bestDelta = delta; + nearest = weights[i].name; + } + } + return nearest; +} + +static NSArray *FLEXSymbolicTraitNames(NSFontDescriptorSymbolicTraits traits) { + NSMutableArray *names = [NSMutableArray array]; + if (traits & NSFontDescriptorTraitBold) { [names addObject:@"bold"]; } + if (traits & NSFontDescriptorTraitItalic) { [names addObject:@"italic"]; } + if (traits & NSFontDescriptorTraitExpanded) { [names addObject:@"expanded"]; } + if (traits & NSFontDescriptorTraitCondensed) { [names addObject:@"condensed"]; } + if (traits & NSFontDescriptorTraitMonoSpace) { [names addObject:@"monoSpace"]; } + if (traits & NSFontDescriptorTraitVertical) { [names addObject:@"vertical"]; } + if (traits & NSFontDescriptorTraitUIOptimized) { [names addObject:@"uiOptimized"]; } + return names; +} + +@implementation FLEXAppKitFont + ++ (nullable FLEXAppKitFont *)fontForObject:(id)object { + NSFont *font = FLEXFontFromCarrier(object); + if (font == nil) { + return nil; + } + + NSDictionary *traitsDict = [font.fontDescriptor objectForKey:NSFontTraitsAttribute]; + NSNumber *weightNumber = traitsDict[NSFontWeightTrait]; + CGFloat weight = weightNumber != nil ? weightNumber.doubleValue : 0.0; + + FLEXAppKitFont *result = [FLEXAppKitFont new]; + result.familyName = font.familyName ?: font.fontName; + result.pointSize = font.pointSize; + result.weightTrait = weight; + result.weightName = FLEXNearestWeightName(weight); + result.postScriptName = font.fontName; + result.traits = FLEXSymbolicTraitNames(font.fontDescriptor.symbolicTraits); + return result; +} + +@end + +#endif // TARGET_OS_OSX diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h new file mode 100644 index 0000000000..5eee883606 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h @@ -0,0 +1,57 @@ +// +// FLEXAppKitViewSnapshot.h +// FLEX +// +// An immutable per-node record produced by FLEXAppKitWalker — the macOS analog +// of FHSViewSnapshot. Captures only the facts read on the main thread; holds no +// live NSView, so it is safe to serialize off-main. +// +// SPEC: domain.walker +// + +#import +#import + +@class FLEXAppKitFont; + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitViewSnapshot : NSObject + +/// The real runtime class via object_getClass — the private subclass, not an AX role. +@property (nonatomic, readonly, copy) NSString *className; + +/// Raw NSView frame, in its superview's (bottom-left origin) coordinates. +@property (nonatomic, readonly) CGRect frame; + +/// Normalized top-left rect, relative to the full window frame (titlebar included). +@property (nonatomic, readonly) CGRect frameTopLeft; + +/// The view's own isFlipped — emitted alongside frame so a consumer never infers +/// a top-left origin from `frame` alone. +@property (nonatomic, readonly) BOOL isFlipped; + +@property (nonatomic, readonly) BOOL hidden; +@property (nonatomic, readonly) double alpha; +@property (nonatomic, readonly, copy, nullable) NSString *identifier; + +/// Decomposed font where the view (or its cell) carries one; nil otherwise. +@property (nonatomic, readonly, nullable) FLEXAppKitFont *font; + +@property (nonatomic, readonly, copy) NSArray *children; + +- (instancetype)initWithClassName:(NSString *)className + frame:(CGRect)frame + frameTopLeft:(CGRect)frameTopLeft + isFlipped:(BOOL)isFlipped + hidden:(BOOL)hidden + alpha:(double)alpha + identifier:(nullable NSString *)identifier + font:(nullable FLEXAppKitFont *)font + children:(NSArray *)children NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m new file mode 100644 index 0000000000..a5052c90f5 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m @@ -0,0 +1,36 @@ +// +// FLEXAppKitViewSnapshot.m +// FLEX +// +// SPEC: domain.walker +// + +#import "FLEXAppKitViewSnapshot.h" + +@implementation FLEXAppKitViewSnapshot + +- (instancetype)initWithClassName:(NSString *)className + frame:(CGRect)frame + frameTopLeft:(CGRect)frameTopLeft + isFlipped:(BOOL)isFlipped + hidden:(BOOL)hidden + alpha:(double)alpha + identifier:(nullable NSString *)identifier + font:(nullable FLEXAppKitFont *)font + children:(NSArray *)children { + self = [super init]; + if (self) { + _className = [className copy]; + _frame = frame; + _frameTopLeft = frameTopLeft; + _isFlipped = isFlipped; + _hidden = hidden; + _alpha = alpha; + _identifier = [identifier copy]; + _font = font; + _children = [children copy]; + } + return self; +} + +@end diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h new file mode 100644 index 0000000000..fba30001ce --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h @@ -0,0 +1,32 @@ +// +// FLEXAppKitWalker.h +// FLEX +// +// The macOS view-tree walker: NSApp → NSWindow → NSView, capturing the per-node +// facts in FLEXAppKitViewSnapshot. The macOS analog of FHSView. +// +// Threading: every method must be called on the target's main thread. Main-thread +// marshaling, the socket, and the node-id registry are the headless server's job, +// not the walker's (see domain.walker). +// +// SPEC: domain.walker +// + +#import + +@class FLEXAppKitViewSnapshot; +@class NSView; +@class NSWindow; + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitWalker : NSObject + +/// Recursively snapshot `view` and its subtree. Frames are normalized against +/// `window`'s full frame (titlebar included); pass the view's window. When `window` +/// is nil, `frameTopLeft` falls back to the raw frame. ++ (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view inWindow:(nullable NSWindow *)window; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m new file mode 100644 index 0000000000..8a63d95110 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m @@ -0,0 +1,59 @@ +// +// FLEXAppKitWalker.m +// FLEX +// +// SPEC: domain.walker +// + +#import "FLEXAppKitWalker.h" + +#if TARGET_OS_OSX + +#import "FLEXAppKitViewSnapshot.h" +#import "FLEXAppKitFont.h" +#import +#import + +@implementation FLEXAppKitWalker + ++ (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view inWindow:(nullable NSWindow *)window { + NSString *className = NSStringFromClass(object_getClass(view)); + FLEXAppKitFont *font = [FLEXAppKitFont fontForObject:view]; + + NSMutableArray *children = + [NSMutableArray arrayWithCapacity:view.subviews.count]; + for (NSView *subview in view.subviews) { + [children addObject:[self snapshotForView:subview inWindow:window]]; + } + + return [[FLEXAppKitViewSnapshot alloc] initWithClassName:className + frame:view.frame + frameTopLeft:[self topLeftFrameForView:view + inWindow:window] + isFlipped:view.isFlipped + hidden:view.isHidden + alpha:view.alphaValue + identifier:view.identifier + font:font + children:children]; +} + +/// Normalized top-left rect, full-window-frame-relative (titlebar included), per +/// domain.walker. Computed through screen coordinates so that per-view isFlipped is +/// resolved by AppKit's own conversion rather than by manual y-flipping — the #1 +/// silent-correctness trap. ++ (CGRect)topLeftFrameForView:(NSView *)view inWindow:(nullable NSWindow *)window { + if (window == nil) { + return view.frame; + } + NSRect inWindow = [view convertRect:view.bounds toView:nil]; + NSRect inScreen = [window convertRectToScreen:inWindow]; + NSRect windowFrame = window.frame; + CGFloat x = NSMinX(inScreen) - NSMinX(windowFrame); + CGFloat yFromTop = NSMaxY(windowFrame) - NSMaxY(inScreen); + return CGRectMake(x, yFromTop, NSWidth(inScreen), NSHeight(inScreen)); +} + +@end + +#endif // TARGET_OS_OSX diff --git a/DevProbe/main.m b/DevProbe/main.m new file mode 100644 index 0000000000..986e87df87 --- /dev/null +++ b/DevProbe/main.m @@ -0,0 +1,100 @@ +// +// main.m — FLEXAppKitProbe +// +// A scoped correctness harness for FLEXAppKitWalker. Builds only against FLEXAppKit +// (not the UIKit FLEX target), so it runs on macOS via `swift run FLEXAppKitProbe`. +// Asserts the walker's output against geometry/font facts computed independently. +// +// This is dev tooling, not part of the upstream library. +// + +#import +#import +#import +@import FLEXAppKit; + +@interface FLEXProbeView : NSView +@end +@implementation FLEXProbeView +@end + +@interface FLEXFlippedView : NSView +@end +@implementation FLEXFlippedView +- (BOOL)isFlipped { return YES; } +@end + +static int gFailures = 0; + +static void check(BOOL cond, NSString *msg) { + printf(" %s: %s\n", cond ? "ok" : "FAIL", msg.UTF8String); + if (!cond) { gFailures++; } +} + +static BOOL approx(CGFloat a, CGFloat b) { return fabs(a - b) <= 0.5; } + +int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + + // 1. Real runtime class via object_getClass + FLEXProbeView *custom = [[FLEXProbeView alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]; + FLEXAppKitViewSnapshot *cs = [FLEXAppKitWalker snapshotForView:custom inWindow:nil]; + check([cs.className isEqualToString:@"FLEXProbeView"], + [NSString stringWithFormat:@"real class == FLEXProbeView (got %@)", cs.className]); + + // A titled window; its content bottom-left coincides with the window frame + // bottom-left (titlebar is at the top), so winH = contentH + titlebar. + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300) + styleMask:NSWindowStyleMaskTitled + backing:NSBackingStoreBuffered + defer:NO]; + NSView *content = window.contentView; + CGFloat winH = window.frame.size.height; + CGFloat titlebar = winH - 300; + + // 2a. Non-flipped subview: raw frame + normalized top-left + isFlipped + NSView *plain = [[NSView alloc] initWithFrame:NSMakeRect(50, 40, 100, 20)]; + [content addSubview:plain]; + FLEXAppKitViewSnapshot *ps = [FLEXAppKitWalker snapshotForView:plain inWindow:window]; + check(approx(ps.frame.origin.x, 50) && approx(ps.frame.origin.y, 40), + @"raw frame preserved in AppKit (bottom-left) coords"); + check(ps.isFlipped == NO, @"isFlipped == NO for a default view"); + check(approx(ps.frameTopLeft.origin.x, 50), + [NSString stringWithFormat:@"normalized x == 50 (got %.1f)", ps.frameTopLeft.origin.x]); + check(approx(ps.frameTopLeft.origin.y, winH - 60), + [NSString stringWithFormat:@"normalized yTop == %.1f (got %.1f)", winH - 60, ps.frameTopLeft.origin.y]); + + // 2b. Flipped container: isFlipped reported, normalized geometry still correct + FLEXFlippedView *flipped = [[FLEXFlippedView alloc] initWithFrame:NSMakeRect(0, 0, 400, 300)]; + [content addSubview:flipped]; + NSView *inFlipped = [[NSView alloc] initWithFrame:NSMakeRect(50, 40, 100, 20)]; + [flipped addSubview:inFlipped]; + FLEXAppKitViewSnapshot *fs = [FLEXAppKitWalker snapshotForView:flipped inWindow:window]; + check(fs.isFlipped == YES, @"isFlipped == YES for a flipped container"); + FLEXAppKitViewSnapshot *ifs = fs.children.firstObject; + check(ifs != nil && approx(ifs.frameTopLeft.origin.y, titlebar + 40), + [NSString stringWithFormat:@"flipped child yTop == %.1f (got %.1f)", + titlebar + 40, ifs ? ifs.frameTopLeft.origin.y : -1]); + + // 3. Font decomposition: raw weight trait AND nearest name, no lossy conversion + NSTextField *label = [NSTextField labelWithString:@"Hi"]; + label.font = [NSFont systemFontOfSize:13 weight:NSFontWeightSemibold]; + [content addSubview:label]; + FLEXAppKitViewSnapshot *ls = [FLEXAppKitWalker snapshotForView:label inWindow:window]; + check(ls.font != nil, @"label reports a font"); + check(ls.font && approx(ls.font.pointSize, 13), @"font pointSize == 13"); + check(ls.font && [ls.font.weightName isEqualToString:@"semibold"], + [NSString stringWithFormat:@"weightName == semibold (got %@)", ls.font.weightName]); + check(ls.font && approx(ls.font.weightTrait, NSFontWeightSemibold), + @"weightTrait ~ NSFontWeightSemibold (raw, not converted)"); + check(ls.font.postScriptName.length > 0, @"postScriptName present"); + + // 4. No font carrier -> null, a success not an error + check(ps.font == nil, @"plain NSView reports no font (null)"); + + printf("\n%s (%d failure%s)\n", gFailures == 0 ? "ALL PASS" : "FAILURES", + gFailures, gFailures == 1 ? "" : "s"); + return gFailures == 0 ? 0 : 1; + } +} diff --git a/Package.swift b/Package.swift index 81a3203831..7a13db243c 100644 --- a/Package.swift +++ b/Package.swift @@ -3,111 +3,128 @@ import PackageDescription enum FLEXBuildOptions { - /// Set this to `true` to use `.unsafeFlags` to silence warnings. - static let silenceWarnings = false + /// Set this to `true` to use `.unsafeFlags` to silence warnings. + static let silenceWarnings = false } #if swift(>=5.9) -let platforms: [PackageDescription.SupportedPlatform] = [.iOS(.v12)] + let platforms: [PackageDescription.SupportedPlatform] = [.iOS(.v12)] #elseif swift(>=5.7) -let platforms: [PackageDescription.SupportedPlatform] = [.iOS(.v11)] + let platforms: [PackageDescription.SupportedPlatform] = [.iOS(.v11)] #else -let platforms: [PackageDescription.SupportedPlatform] = [.iOS(.v10)] + let platforms: [PackageDescription.SupportedPlatform] = [.iOS(.v10)] #endif let package = Package( - name: "FLEX", - platforms: platforms, - products: [ - .library(name: "FLEX", targets: ["FLEX"]) - ], - targets: [ - .target( - name: "FLEX", - path: "Classes", - exclude: [ - "Info.plist", - "Utility/APPLE_LICENSE", - "Network/OSCache/LICENSE.md", - "Network/PonyDebugger/LICENSE", - "GlobalStateExplorers/DatabaseBrowser/LICENSE", - "GlobalStateExplorers/Keychain/SSKeychain_LICENSE", - "GlobalStateExplorers/SystemLog/LLVM_LICENSE.TXT", - ], - publicHeadersPath: "Headers", - cSettings: .headerSearchPaths + .warningFlags, - linkerSettings: [ - .linkedFramework("CoreGraphics"), - .linkedLibrary("sqlite3"), - .linkedLibrary("z"), - ] - ) - ], - // Required to compile FLEXSwiftInternal.mm - cxxLanguageStandard: .gnucxx11 + name: "FLEX", + platforms: platforms, + products: [ + .library(name: "FLEX", targets: ["FLEX"]), + .library(name: "FLEXAppKit", targets: ["FLEXAppKit"]), + ], + targets: [ + .target( + name: "FLEX", + path: "Classes", + exclude: [ + "Info.plist", + "ViewHierarchy/AppKit", + "Utility/APPLE_LICENSE", + "Network/OSCache/LICENSE.md", + "Network/PonyDebugger/LICENSE", + "GlobalStateExplorers/DatabaseBrowser/LICENSE", + "GlobalStateExplorers/Keychain/SSKeychain_LICENSE", + "GlobalStateExplorers/SystemLog/LLVM_LICENSE.TXT", + ], + publicHeadersPath: "Headers", + cSettings: .headerSearchPaths + .warningFlags, + linkerSettings: [ + .linkedFramework("CoreGraphics"), + .linkedLibrary("sqlite3"), + .linkedLibrary("z"), + ] + ), + .target( + name: "FLEXAppKit", + path: "Classes/ViewHierarchy/AppKit", + publicHeadersPath: ".", + linkerSettings: [ + .linkedFramework("AppKit", .when(platforms: [.macOS])) + ] + ), + .target( + name: "FLEXAppKitProbe", + dependencies: ["FLEXAppKit"], + path: "DevProbe" + ), + ], + // Required to compile FLEXSwiftInternal.mm + cxxLanguageStandard: .gnucxx11 ) extension Array where Element == CSetting { - static var warningFlags: [Element] { - if FLEXBuildOptions.silenceWarnings { - return [.unsafeFlags([ - "-Wno-deprecated-declarations", - "-Wno-strict-prototypes", - "-Wno-unsupported-availability-guard", - ])] - } - - return [] + static var warningFlags: [Element] { + if FLEXBuildOptions.silenceWarnings { + return [ + .unsafeFlags([ + "-Wno-deprecated-declarations", + "-Wno-strict-prototypes", + "-Wno-unsupported-availability-guard", + ]) + ] } - /// These are the header search paths needed for FLEX to compile, not - /// the headers used by projects linking against FLEX. - /// - /// Do not modify the contents of this property by hand; - /// Instead, run `bash generate-spm-headers.sh | grep headerSearchPath | pbcopy` - /// and paste (and indent) the result below. Do this any time new folders are added. - static var headerSearchPaths: [Element] { - [ - .headerSearchPath("Classes"), - .headerSearchPath("Core"), - .headerSearchPath("Core/Controllers"), - .headerSearchPath("Core/Views"), - .headerSearchPath("Core/Views/Cells"), - .headerSearchPath("Core/Views/Carousel"), - .headerSearchPath("ObjectExplorers"), - .headerSearchPath("ObjectExplorers/Sections"), - .headerSearchPath("ObjectExplorers/Sections/Shortcuts"), - .headerSearchPath("Network"), - .headerSearchPath("Network/PonyDebugger"), - .headerSearchPath("Network/OSCache"), - .headerSearchPath("Toolbar"), - .headerSearchPath("Manager"), - .headerSearchPath("Manager/Private"), - .headerSearchPath("Editing"), - .headerSearchPath("Editing/ArgumentInputViews"), - .headerSearchPath("Headers"), - .headerSearchPath("ExplorerInterface"), - .headerSearchPath("ExplorerInterface/Tabs"), - .headerSearchPath("ExplorerInterface/Bookmarks"), - .headerSearchPath("GlobalStateExplorers"), - .headerSearchPath("GlobalStateExplorers/Globals"), - .headerSearchPath("GlobalStateExplorers/Keychain"), - .headerSearchPath("GlobalStateExplorers/FileBrowser"), - .headerSearchPath("GlobalStateExplorers/SystemLog"), - .headerSearchPath("GlobalStateExplorers/DatabaseBrowser"), - .headerSearchPath("GlobalStateExplorers/RuntimeBrowser"), - .headerSearchPath("GlobalStateExplorers/RuntimeBrowser/DataSources"), - .headerSearchPath("ViewHierarchy"), - .headerSearchPath("ViewHierarchy/SnapshotExplorer"), - .headerSearchPath("ViewHierarchy/SnapshotExplorer/Scene"), - .headerSearchPath("ViewHierarchy/TreeExplorer"), - .headerSearchPath("Utility"), - .headerSearchPath("Utility/Runtime"), - .headerSearchPath("Utility/Runtime/Objc"), - .headerSearchPath("Utility/Runtime/Objc/Reflection"), - .headerSearchPath("Utility/Categories"), - .headerSearchPath("Utility/Categories/Private"), - .headerSearchPath("Utility/Keyboard") - ] - } + return [] + } + + /// These are the header search paths needed for FLEX to compile, not + /// the headers used by projects linking against FLEX. + /// + /// Do not modify the contents of this property by hand; + /// Instead, run `bash generate-spm-headers.sh | grep headerSearchPath | pbcopy` + /// and paste (and indent) the result below. Do this any time new folders are added. + static var headerSearchPaths: [Element] { + [ + .headerSearchPath("Classes"), + .headerSearchPath("Core"), + .headerSearchPath("Core/Controllers"), + .headerSearchPath("Core/Views"), + .headerSearchPath("Core/Views/Cells"), + .headerSearchPath("Core/Views/Carousel"), + .headerSearchPath("ObjectExplorers"), + .headerSearchPath("ObjectExplorers/Sections"), + .headerSearchPath("ObjectExplorers/Sections/Shortcuts"), + .headerSearchPath("Network"), + .headerSearchPath("Network/PonyDebugger"), + .headerSearchPath("Network/OSCache"), + .headerSearchPath("Toolbar"), + .headerSearchPath("Manager"), + .headerSearchPath("Manager/Private"), + .headerSearchPath("Editing"), + .headerSearchPath("Editing/ArgumentInputViews"), + .headerSearchPath("Headers"), + .headerSearchPath("ExplorerInterface"), + .headerSearchPath("ExplorerInterface/Tabs"), + .headerSearchPath("ExplorerInterface/Bookmarks"), + .headerSearchPath("GlobalStateExplorers"), + .headerSearchPath("GlobalStateExplorers/Globals"), + .headerSearchPath("GlobalStateExplorers/Keychain"), + .headerSearchPath("GlobalStateExplorers/FileBrowser"), + .headerSearchPath("GlobalStateExplorers/SystemLog"), + .headerSearchPath("GlobalStateExplorers/DatabaseBrowser"), + .headerSearchPath("GlobalStateExplorers/RuntimeBrowser"), + .headerSearchPath("GlobalStateExplorers/RuntimeBrowser/DataSources"), + .headerSearchPath("ViewHierarchy"), + .headerSearchPath("ViewHierarchy/SnapshotExplorer"), + .headerSearchPath("ViewHierarchy/SnapshotExplorer/Scene"), + .headerSearchPath("ViewHierarchy/TreeExplorer"), + .headerSearchPath("Utility"), + .headerSearchPath("Utility/Runtime"), + .headerSearchPath("Utility/Runtime/Objc"), + .headerSearchPath("Utility/Runtime/Objc/Reflection"), + .headerSearchPath("Utility/Categories"), + .headerSearchPath("Utility/Categories/Private"), + .headerSearchPath("Utility/Keyboard"), + ] + } } From c563353714512271441bed39f723abc3d1747f83 Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Fri, 5 Jun 2026 22:37:03 -0500 Subject: [PATCH 2/7] feat: complete the minimal AppKit walker surface 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 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). --- .../ViewHierarchy/AppKit/FLEXAppKitColor.h | 36 ++++++ .../ViewHierarchy/AppKit/FLEXAppKitColor.m | 80 +++++++++++++ .../ViewHierarchy/AppKit/FLEXAppKitLayer.h | 40 +++++++ .../ViewHierarchy/AppKit/FLEXAppKitLayer.m | 60 ++++++++++ .../AppKit/FLEXAppKitViewSnapshot.h | 25 ++-- .../AppKit/FLEXAppKitViewSnapshot.m | 27 +---- .../AppKit/FLEXAppKitViewSnapshot_Internal.h | 31 +++++ .../ViewHierarchy/AppKit/FLEXAppKitWalker.h | 5 + .../ViewHierarchy/AppKit/FLEXAppKitWalker.m | 110 +++++++++++++++--- .../AppKit/FLEXAppKitWindowSnapshot.h | 35 ++++++ .../AppKit/FLEXAppKitWindowSnapshot.m | 11 ++ .../FLEXAppKitWindowSnapshot_Internal.h | 26 +++++ DevProbe/main.m | 59 ++++++++-- 13 files changed, 483 insertions(+), 62 deletions(-) create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.m create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h new file mode 100644 index 0000000000..c621abb58b --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h @@ -0,0 +1,36 @@ +// +// FLEXAppKitColor.h +// FLEX +// +// A resolved color fact: the unambiguous sRGB hex snapshot PLUS the catalog/ +// dynamic name where one is available (what a native reimplementation actually +// uses) PLUS the appearance context it was resolved under. Catalog/dynamic +// NSColors return nil or throw if components are read without first resolving +// through an appearance + a concrete color space — this type does that. +// +// SPEC: domain.walker +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitColor : NSObject + +/// Resolve an NSColor (or a CGColorRef) under `appearance`. Returns nil only if +/// the input is nil. `appearance` may be nil (resolves under the current default). ++ (nullable FLEXAppKitColor *)colorFromColor:(nullable id)nsColorOrCGColor + inAppearance:(nullable id)appearance; + +/// sRGB hex "#RRGGBBAA"; nil if the color could not be resolved to components. +@property (nonatomic, readonly, copy, nullable) NSString *hex; +/// Catalog/dynamic name (e.g. "controlAccentColor") where the color is a catalog +/// color; nil otherwise. +@property (nonatomic, readonly, copy, nullable) NSString *catalogName; +/// The appearance the color was resolved under (e.g. "NSAppearanceNameDarkAqua"). +@property (nonatomic, readonly, copy, nullable) NSString *appearanceName; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m new file mode 100644 index 0000000000..128d7b6c62 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m @@ -0,0 +1,80 @@ +// +// FLEXAppKitColor.m +// FLEX +// +// SPEC: domain.walker +// + +#import "FLEXAppKitColor.h" + +#if TARGET_OS_OSX + +#import + +@interface FLEXAppKitColor () +@property (nonatomic, copy, nullable) NSString *hex; +@property (nonatomic, copy, nullable) NSString *catalogName; +@property (nonatomic, copy, nullable) NSString *appearanceName; +@end + +@implementation FLEXAppKitColor + ++ (nullable FLEXAppKitColor *)colorFromColor:(nullable id)input + inAppearance:(nullable id)appearance { + if (input == nil) { + return nil; + } + + NSColor *color = nil; + if ([input isKindOfClass:[NSColor class]]) { + color = input; + } else if (CFGetTypeID((__bridge CFTypeRef)input) == CGColorGetTypeID()) { + color = [NSColor colorWithCGColor:(__bridge CGColorRef)input]; + } + if (color == nil) { + return nil; + } + + FLEXAppKitColor *result = [FLEXAppKitColor new]; + + // Catalog/dynamic NAME, only where the color genuinely is a catalog color + // (reading colorNameComponent on a non-catalog color throws). + if (@available(macOS 10.14, *)) { + if (color.type == NSColorTypeCatalog) { + result.catalogName = color.colorNameComponent; + } + } + + NSAppearance *resolveAppearance = [appearance isKindOfClass:[NSAppearance class]] ? appearance : nil; + result.appearanceName = resolveAppearance.name; + + // Resolve to sRGB components UNDER the appearance — required for catalog/dynamic + // colors, which otherwise return nil or throw. + __block NSColor *resolved = nil; + void (^toSRGB)(void) = ^{ + resolved = [color colorUsingColorSpace:[NSColorSpace sRGBColorSpace]]; + }; + if (resolveAppearance != nil) { + if (@available(macOS 11.0, *)) { + [resolveAppearance performAsCurrentDrawingAppearance:toSRGB]; + } else { + toSRGB(); + } + } else { + toSRGB(); + } + + if (resolved != nil) { + int r = (int)lround(resolved.redComponent * 255.0); + int g = (int)lround(resolved.greenComponent * 255.0); + int b = (int)lround(resolved.blueComponent * 255.0); + int a = (int)lround(resolved.alphaComponent * 255.0); + result.hex = [NSString stringWithFormat:@"#%02X%02X%02X%02X", r, g, b, a]; + } + + return result; +} + +@end + +#endif // TARGET_OS_OSX diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h new file mode 100644 index 0000000000..3b330f71c6 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h @@ -0,0 +1,40 @@ +// +// FLEXAppKitLayer.h +// FLEX +// +// CALayer facts where a view is layer-backed, plus the recursive sublayer tree. +// This is a structure PARALLEL to the view tree: layer.sublayers != view.subviews, +// and standalone sublayers (backing no view) are included here. CALayer is the +// same class on macOS and iOS, so this is cross-platform. +// +// SPEC: domain.walker +// + +#import +#import + +@class FLEXAppKitColor; + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitLayer : NSObject + ++ (instancetype)layerFromLayer:(CALayer *)layer inAppearance:(nullable id)appearance; + +@property (nonatomic, readonly, copy) NSString *className; +@property (nonatomic, readonly) double cornerRadius; +@property (nonatomic, readonly) BOOL masksToBounds; +@property (nonatomic, readonly) double opacity; +@property (nonatomic, readonly) double borderWidth; +@property (nonatomic, readonly, nullable) FLEXAppKitColor *backgroundColor; +@property (nonatomic, readonly, nullable) FLEXAppKitColor *borderColor; +@property (nonatomic, readonly) double shadowOpacity; +@property (nonatomic, readonly) double shadowRadius; +@property (nonatomic, readonly) CGSize shadowOffset; +@property (nonatomic, readonly, nullable) FLEXAppKitColor *shadowColor; +/// The parallel sublayer tree, including standalone sublayers backing no view. +@property (nonatomic, readonly, copy) NSArray *sublayers; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m new file mode 100644 index 0000000000..b08c0780d1 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m @@ -0,0 +1,60 @@ +// +// FLEXAppKitLayer.m +// FLEX +// +// SPEC: domain.walker +// + +#import "FLEXAppKitLayer.h" + +#if TARGET_OS_OSX + +#import "FLEXAppKitColor.h" +#import + +@interface FLEXAppKitLayer () +@property (nonatomic, copy) NSString *className; +@property (nonatomic) double cornerRadius; +@property (nonatomic) BOOL masksToBounds; +@property (nonatomic) double opacity; +@property (nonatomic) double borderWidth; +@property (nonatomic, nullable) FLEXAppKitColor *backgroundColor; +@property (nonatomic, nullable) FLEXAppKitColor *borderColor; +@property (nonatomic) double shadowOpacity; +@property (nonatomic) double shadowRadius; +@property (nonatomic) CGSize shadowOffset; +@property (nonatomic, nullable) FLEXAppKitColor *shadowColor; +@property (nonatomic, copy) NSArray *sublayers; +@end + +@implementation FLEXAppKitLayer + ++ (instancetype)layerFromLayer:(CALayer *)layer inAppearance:(nullable id)appearance { + FLEXAppKitLayer *result = [FLEXAppKitLayer new]; + result.className = NSStringFromClass(object_getClass(layer)); + result.cornerRadius = layer.cornerRadius; + result.masksToBounds = layer.masksToBounds; + result.opacity = layer.opacity; + result.borderWidth = layer.borderWidth; + result.backgroundColor = [FLEXAppKitColor colorFromColor:(__bridge id)layer.backgroundColor + inAppearance:appearance]; + result.borderColor = [FLEXAppKitColor colorFromColor:(__bridge id)layer.borderColor + inAppearance:appearance]; + result.shadowOpacity = layer.shadowOpacity; + result.shadowRadius = layer.shadowRadius; + result.shadowOffset = layer.shadowOffset; + result.shadowColor = [FLEXAppKitColor colorFromColor:(__bridge id)layer.shadowColor + inAppearance:appearance]; + + NSMutableArray *subs = + [NSMutableArray arrayWithCapacity:layer.sublayers.count]; + for (CALayer *sub in layer.sublayers) { + [subs addObject:[self layerFromLayer:sub inAppearance:appearance]]; + } + result.sublayers = subs; + return result; +} + +@end + +#endif // TARGET_OS_OSX diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h index 5eee883606..ef2c8a81c7 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h @@ -13,6 +13,7 @@ #import @class FLEXAppKitFont; +@class FLEXAppKitLayer; NS_ASSUME_NONNULL_BEGIN @@ -35,22 +36,22 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) double alpha; @property (nonatomic, readonly, copy, nullable) NSString *identifier; +/// True at an NSHostingView (SwiftUI's AppKit host): below it the class names are +/// SwiftUI internals, but the layer-backed scaffold is still real and traversed. +@property (nonatomic, readonly) BOOL swiftUIBoundary; + +/// NSVisualEffectView.material / blendingMode (string names), where applicable. +@property (nonatomic, readonly, copy, nullable) NSString *material; +@property (nonatomic, readonly, copy, nullable) NSString *blendingMode; + /// Decomposed font where the view (or its cell) carries one; nil otherwise. @property (nonatomic, readonly, nullable) FLEXAppKitFont *font; -@property (nonatomic, readonly, copy) NSArray *children; +/// Layer facts where the view is layer-backed (wantsLayer / non-nil layer); nil +/// otherwise. A nil layer is normal, not a failure (NSView is not always backed). +@property (nonatomic, readonly, nullable) FLEXAppKitLayer *layer; -- (instancetype)initWithClassName:(NSString *)className - frame:(CGRect)frame - frameTopLeft:(CGRect)frameTopLeft - isFlipped:(BOOL)isFlipped - hidden:(BOOL)hidden - alpha:(double)alpha - identifier:(nullable NSString *)identifier - font:(nullable FLEXAppKitFont *)font - children:(NSArray *)children NS_DESIGNATED_INITIALIZER; - -- (instancetype)init NS_UNAVAILABLE; +@property (nonatomic, readonly, copy) NSArray *children; @end diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m index a5052c90f5..6c55bf8596 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m @@ -5,32 +5,7 @@ // SPEC: domain.walker // -#import "FLEXAppKitViewSnapshot.h" +#import "FLEXAppKitViewSnapshot_Internal.h" @implementation FLEXAppKitViewSnapshot - -- (instancetype)initWithClassName:(NSString *)className - frame:(CGRect)frame - frameTopLeft:(CGRect)frameTopLeft - isFlipped:(BOOL)isFlipped - hidden:(BOOL)hidden - alpha:(double)alpha - identifier:(nullable NSString *)identifier - font:(nullable FLEXAppKitFont *)font - children:(NSArray *)children { - self = [super init]; - if (self) { - _className = [className copy]; - _frame = frame; - _frameTopLeft = frameTopLeft; - _isFlipped = isFlipped; - _hidden = hidden; - _alpha = alpha; - _identifier = [identifier copy]; - _font = font; - _children = [children copy]; - } - return self; -} - @end diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h new file mode 100644 index 0000000000..a4bd4fa9aa --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h @@ -0,0 +1,31 @@ +// +// FLEXAppKitViewSnapshot_Internal.h +// FLEX +// +// Internal readwrite surface so FLEXAppKitWalker can populate an otherwise +// immutable snapshot during construction. Not part of the public contract. +// +// SPEC: domain.walker +// + +#import "FLEXAppKitViewSnapshot.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitViewSnapshot () +@property (nonatomic, copy) NSString *className; +@property (nonatomic) CGRect frame; +@property (nonatomic) CGRect frameTopLeft; +@property (nonatomic) BOOL isFlipped; +@property (nonatomic) BOOL hidden; +@property (nonatomic) double alpha; +@property (nonatomic, copy, nullable) NSString *identifier; +@property (nonatomic) BOOL swiftUIBoundary; +@property (nonatomic, copy, nullable) NSString *material; +@property (nonatomic, copy, nullable) NSString *blendingMode; +@property (nonatomic, nullable) FLEXAppKitFont *font; +@property (nonatomic, nullable) FLEXAppKitLayer *layer; +@property (nonatomic, copy) NSArray *children; +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h index fba30001ce..bd2eee3391 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h @@ -15,6 +15,7 @@ #import @class FLEXAppKitViewSnapshot; +@class FLEXAppKitWindowSnapshot; @class NSView; @class NSWindow; @@ -22,6 +23,10 @@ NS_ASSUME_NONNULL_BEGIN @interface FLEXAppKitWalker : NSObject +/// Snapshot every NSApp window as a tree root (key/main/panel identified), each +/// with its contentView subtree. The rooted entry point for a full app walk. ++ (NSArray *)snapshotApplicationWindows; + /// Recursively snapshot `view` and its subtree. Frames are normalized against /// `window`'s full frame (titlebar included); pass the view's window. When `window` /// is nil, `frameTopLeft` falls back to the raw frame. diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m index 8a63d95110..8616ba6fde 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m @@ -9,39 +9,115 @@ #if TARGET_OS_OSX -#import "FLEXAppKitViewSnapshot.h" +#import "FLEXAppKitViewSnapshot_Internal.h" +#import "FLEXAppKitWindowSnapshot_Internal.h" #import "FLEXAppKitFont.h" +#import "FLEXAppKitLayer.h" #import #import +/// True if the view's class chain contains an NSHostingView (SwiftUI's host). The +/// generic NSHostingView has a mangled Swift name, so match by substring +/// across the hierarchy rather than isKindOfClass against a single concrete class. +static BOOL FLEXIsSwiftUIBoundary(NSView *view) { + for (Class cls = object_getClass(view); cls != Nil; cls = class_getSuperclass(cls)) { + const char *name = class_getName(cls); + if (name != NULL && strstr(name, "NSHostingView") != NULL) { + return YES; + } + } + return NO; +} + +static NSString *FLEXMaterialName(NSVisualEffectMaterial material) { + switch (material) { + case NSVisualEffectMaterialTitlebar: return @"titlebar"; + case NSVisualEffectMaterialSelection: return @"selection"; + case NSVisualEffectMaterialMenu: return @"menu"; + case NSVisualEffectMaterialPopover: return @"popover"; + case NSVisualEffectMaterialSidebar: return @"sidebar"; + case NSVisualEffectMaterialHeaderView: return @"headerView"; + case NSVisualEffectMaterialSheet: return @"sheet"; + case NSVisualEffectMaterialWindowBackground: return @"windowBackground"; + case NSVisualEffectMaterialHUDWindow: return @"hudWindow"; + case NSVisualEffectMaterialFullScreenUI: return @"fullScreenUI"; + case NSVisualEffectMaterialToolTip: return @"toolTip"; + case NSVisualEffectMaterialContentBackground: return @"contentBackground"; + case NSVisualEffectMaterialUnderWindowBackground: return @"underWindowBackground"; + case NSVisualEffectMaterialUnderPageBackground: return @"underPageBackground"; + default: return [NSString stringWithFormat:@"material(%ld)", (long)material]; + } +} + +static NSString *FLEXBlendingModeName(NSVisualEffectBlendingMode mode) { + switch (mode) { + case NSVisualEffectBlendingModeBehindWindow: return @"behindWindow"; + case NSVisualEffectBlendingModeWithinWindow: return @"withinWindow"; + default: return [NSString stringWithFormat:@"blendingMode(%ld)", (long)mode]; + } +} + @implementation FLEXAppKitWalker ++ (NSArray *)snapshotApplicationWindows { + NSApplication *app = NSApplication.sharedApplication; + NSWindow *keyWindow = app.keyWindow; + NSWindow *mainWindow = app.mainWindow; + + NSMutableArray *result = [NSMutableArray array]; + for (NSWindow *window in app.windows) { + FLEXAppKitWindowSnapshot *snapshot = [FLEXAppKitWindowSnapshot new]; + snapshot.className = NSStringFromClass(object_getClass(window)); + snapshot.title = window.title; + snapshot.identifier = window.identifier; + snapshot.isKeyWindow = (window == keyWindow); + snapshot.isMainWindow = (window == mainWindow); + snapshot.isVisible = window.isVisible; + snapshot.isPanel = [window isKindOfClass:[NSPanel class]]; + snapshot.frame = window.frame; + NSView *content = window.contentView; + snapshot.contentView = content ? [self snapshotForView:content inWindow:window] : nil; + [result addObject:snapshot]; + } + return result; +} + + (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view inWindow:(nullable NSWindow *)window { - NSString *className = NSStringFromClass(object_getClass(view)); - FLEXAppKitFont *font = [FLEXAppKitFont fontForObject:view]; + FLEXAppKitViewSnapshot *snapshot = [FLEXAppKitViewSnapshot new]; + snapshot.className = NSStringFromClass(object_getClass(view)); + snapshot.frame = view.frame; + snapshot.frameTopLeft = [self topLeftFrameForView:view inWindow:window]; + snapshot.isFlipped = view.isFlipped; + snapshot.hidden = view.isHidden; + snapshot.alpha = view.alphaValue; + snapshot.identifier = view.identifier; + snapshot.swiftUIBoundary = FLEXIsSwiftUIBoundary(view); + snapshot.font = [FLEXAppKitFont fontForObject:view]; + + if ([view isKindOfClass:[NSVisualEffectView class]]) { + NSVisualEffectView *effect = (NSVisualEffectView *)view; + snapshot.material = FLEXMaterialName(effect.material); + snapshot.blendingMode = FLEXBlendingModeName(effect.blendingMode); + } + + // Layer facts only where the view is layer-backed — a nil layer is normal. + if (view.layer != nil) { + snapshot.layer = [FLEXAppKitLayer layerFromLayer:view.layer + inAppearance:view.effectiveAppearance]; + } NSMutableArray *children = [NSMutableArray arrayWithCapacity:view.subviews.count]; for (NSView *subview in view.subviews) { [children addObject:[self snapshotForView:subview inWindow:window]]; } - - return [[FLEXAppKitViewSnapshot alloc] initWithClassName:className - frame:view.frame - frameTopLeft:[self topLeftFrameForView:view - inWindow:window] - isFlipped:view.isFlipped - hidden:view.isHidden - alpha:view.alphaValue - identifier:view.identifier - font:font - children:children]; + snapshot.children = children; + return snapshot; } /// Normalized top-left rect, full-window-frame-relative (titlebar included), per -/// domain.walker. Computed through screen coordinates so that per-view isFlipped is -/// resolved by AppKit's own conversion rather than by manual y-flipping — the #1 -/// silent-correctness trap. +/// domain.walker. Computed through screen coordinates so per-view isFlipped is +/// resolved by AppKit's own conversion rather than manual y-flipping. + (CGRect)topLeftFrameForView:(NSView *)view inWindow:(nullable NSWindow *)window { if (window == nil) { return view.frame; diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h new file mode 100644 index 0000000000..2c6d420d5b --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h @@ -0,0 +1,35 @@ +// +// FLEXAppKitWindowSnapshot.h +// FLEX +// +// A top-level NSWindow root produced by FLEXAppKitWalker. Each on-screen window +// is a tree root; its contentView subtree hangs below. +// +// SPEC: domain.walker +// + +#import +#import + +@class FLEXAppKitViewSnapshot; + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitWindowSnapshot : NSObject + +/// The real runtime NSWindow subclass via object_getClass. +@property (nonatomic, readonly, copy) NSString *className; +@property (nonatomic, readonly, copy, nullable) NSString *title; +@property (nonatomic, readonly, copy, nullable) NSString *identifier; +@property (nonatomic, readonly) BOOL isKeyWindow; +@property (nonatomic, readonly) BOOL isMainWindow; +@property (nonatomic, readonly) BOOL isVisible; +@property (nonatomic, readonly) BOOL isPanel; +/// Window frame in screen coordinates (bottom-left origin). +@property (nonatomic, readonly) CGRect frame; +/// Snapshot of the window's contentView subtree; nil if there is no contentView. +@property (nonatomic, readonly, nullable) FLEXAppKitViewSnapshot *contentView; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.m new file mode 100644 index 0000000000..1c06645f3b --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.m @@ -0,0 +1,11 @@ +// +// FLEXAppKitWindowSnapshot.m +// FLEX +// +// SPEC: domain.walker +// + +#import "FLEXAppKitWindowSnapshot_Internal.h" + +@implementation FLEXAppKitWindowSnapshot +@end diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h new file mode 100644 index 0000000000..e72432b3c9 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h @@ -0,0 +1,26 @@ +// +// FLEXAppKitWindowSnapshot_Internal.h +// FLEX +// +// Internal readwrite surface for FLEXAppKitWalker. Not part of the public contract. +// +// SPEC: domain.walker +// + +#import "FLEXAppKitWindowSnapshot.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitWindowSnapshot () +@property (nonatomic, copy) NSString *className; +@property (nonatomic, copy, nullable) NSString *title; +@property (nonatomic, copy, nullable) NSString *identifier; +@property (nonatomic) BOOL isKeyWindow; +@property (nonatomic) BOOL isMainWindow; +@property (nonatomic) BOOL isVisible; +@property (nonatomic) BOOL isPanel; +@property (nonatomic) CGRect frame; +@property (nonatomic, nullable) FLEXAppKitViewSnapshot *contentView; +@end + +NS_ASSUME_NONNULL_END diff --git a/DevProbe/main.m b/DevProbe/main.m index 986e87df87..88881c1d0f 100644 --- a/DevProbe/main.m +++ b/DevProbe/main.m @@ -3,12 +3,12 @@ // // A scoped correctness harness for FLEXAppKitWalker. Builds only against FLEXAppKit // (not the UIKit FLEX target), so it runs on macOS via `swift run FLEXAppKitProbe`. -// Asserts the walker's output against geometry/font facts computed independently. -// -// This is dev tooling, not part of the upstream library. +// Asserts the walker's output against geometry/font/color/layer facts computed +// independently. This is dev tooling, not part of the upstream library. // #import +#import #import #import @import FLEXAppKit; @@ -43,12 +43,11 @@ int main(void) { check([cs.className isEqualToString:@"FLEXProbeView"], [NSString stringWithFormat:@"real class == FLEXProbeView (got %@)", cs.className]); - // A titled window; its content bottom-left coincides with the window frame - // bottom-left (titlebar is at the top), so winH = contentH + titlebar. NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300) styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO]; + window.title = @"ProbeWindow"; NSView *content = window.contentView; CGFloat winH = window.frame.size.height; CGFloat titlebar = winH - 300; @@ -89,10 +88,56 @@ int main(void) { check(ls.font && approx(ls.font.weightTrait, NSFontWeightSemibold), @"weightTrait ~ NSFontWeightSemibold (raw, not converted)"); check(ls.font.postScriptName.length > 0, @"postScriptName present"); - - // 4. No font carrier -> null, a success not an error check(ps.font == nil, @"plain NSView reports no font (null)"); + // 4. Rooted traversal: NSApp.windows enumeration + NSArray *windows = [FLEXAppKitWalker snapshotApplicationWindows]; + FLEXAppKitWindowSnapshot *probeWindow = nil; + for (FLEXAppKitWindowSnapshot *w in windows) { + if ([w.title isEqualToString:@"ProbeWindow"]) { probeWindow = w; break; } + } + check(probeWindow != nil, @"NSApp.windows enumeration finds ProbeWindow as a root"); + check(probeWindow.className.length > 0 && [probeWindow.className containsString:@"Window"], + [NSString stringWithFormat:@"window real class looks like an NSWindow (got %@)", probeWindow.className]); + check(probeWindow.contentView != nil && probeWindow.contentView.children.count >= 1, + @"window root carries its contentView subtree"); + + // 5. swiftUIBoundary: a class whose name contains NSHostingView trips the flag + Class hostingStub = objc_allocateClassPair([NSView class], "NSHostingViewStub", 0); + objc_registerClassPair(hostingStub); + NSView *fakeHost = [[hostingStub alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]; + FLEXAppKitViewSnapshot *hs = [FLEXAppKitWalker snapshotForView:fakeHost inWindow:nil]; + check(hs.swiftUIBoundary == YES, @"swiftUIBoundary == YES at an NSHostingView-named class"); + check(ps.swiftUIBoundary == NO, @"swiftUIBoundary == NO for a plain view"); + + // 6. Layer sub-shape + NSColor decomposition (standalone, layer-backed) + NSView *backed = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 50, 50)]; + backed.wantsLayer = YES; + backed.layer.cornerRadius = 8; + backed.layer.masksToBounds = YES; + backed.layer.backgroundColor = [NSColor colorWithSRGBRed:1 green:0 blue:0 alpha:1].CGColor; + FLEXAppKitViewSnapshot *bs = [FLEXAppKitWalker snapshotForView:backed inWindow:nil]; + check(bs.layer != nil, @"layer-backed view captures a layer"); + check(bs.layer && approx(bs.layer.cornerRadius, 8), @"layer cornerRadius == 8"); + check(bs.layer && bs.layer.masksToBounds == YES, @"layer masksToBounds == YES"); + check(bs.layer.backgroundColor != nil, @"layer has a decomposed backgroundColor"); + check(bs.layer.backgroundColor && [bs.layer.backgroundColor.hex isEqualToString:@"#FF0000FF"], + [NSString stringWithFormat:@"bg color hex == #FF0000FF (got %@)", bs.layer.backgroundColor.hex]); + + // 6b. nil-layer: a standalone, non-wantsLayer view reports no layer (success) + NSView *unbacked = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]; + FLEXAppKitViewSnapshot *us = [FLEXAppKitWalker snapshotForView:unbacked inWindow:nil]; + check(us.layer == nil, @"unbacked standalone view reports no layer (nil, not error)"); + + // 7. NSVisualEffectView material / blendingMode + NSVisualEffectView *vev = [[NSVisualEffectView alloc] initWithFrame:NSMakeRect(0, 0, 50, 50)]; + vev.material = NSVisualEffectMaterialSidebar; + FLEXAppKitViewSnapshot *vs = [FLEXAppKitWalker snapshotForView:vev inWindow:nil]; + check([vs.material isEqualToString:@"sidebar"], + [NSString stringWithFormat:@"material == sidebar (got %@)", vs.material]); + check(vs.blendingMode.length > 0, + [NSString stringWithFormat:@"blendingMode present (got %@)", vs.blendingMode]); + printf("\n%s (%d failure%s)\n", gFailures == 0 ? "ALL PASS" : "FAILURES", gFailures, gFailures == 1 ? "" : "s"); return gFailures == 0 ? 0 : 1; From 6a1a024aeeef6810d2de83b77b60d37bd600a0ce Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Fri, 5 Jun 2026 22:45:50 -0500 Subject: [PATCH 3/7] feat: complete the node schema + depth bounding in the AppKit walker 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). --- .../AppKit/FLEXAppKitViewSnapshot.h | 22 +++++ .../AppKit/FLEXAppKitViewSnapshot_Internal.h | 6 ++ .../ViewHierarchy/AppKit/FLEXAppKitWalker.h | 16 +++- .../ViewHierarchy/AppKit/FLEXAppKitWalker.m | 80 +++++++++++++++++-- DevProbe/main.m | 35 ++++++++ 5 files changed, 150 insertions(+), 9 deletions(-) diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h index ef2c8a81c7..5cbb9174bb 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h @@ -36,6 +36,28 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) double alpha; @property (nonatomic, readonly, copy, nullable) NSString *identifier; +/// Displayed string where the view is text-bearing (NSControl/NSText); nil otherwise. +/// What the selector grammar's `text` predicate matches against. +@property (nonatomic, readonly, copy, nullable) NSString *text; + +/// The AX role (NSAccessibility), to cross-reference the AX dump. +@property (nonatomic, readonly, copy, nullable) NSString *axRole; + +/// Runtime class hierarchy from the view's class up to NSView (exclusive of the +/// view's own class, which is `className`). An --include field. +@property (nonatomic, readonly, copy) NSArray *superclasses; + +/// Number of NSLayoutConstraints touching this view (both directions). The full +/// list is produced separately; this is the default-projection count. +@property (nonatomic, readonly) NSInteger constraintsCount; + +/// Number of subviews, always reported even when `children` is truncated. +@property (nonatomic, readonly) NSInteger childCount; + +/// True when subviews were omitted because the depth bound was reached. A leaf is +/// never truncated; `childCount` still reports the real subview count. +@property (nonatomic, readonly) BOOL truncated; + /// True at an NSHostingView (SwiftUI's AppKit host): below it the class names are /// SwiftUI internals, but the layer-backed scaffold is still real and traversed. @property (nonatomic, readonly) BOOL swiftUIBoundary; diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h index a4bd4fa9aa..f973cea443 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h @@ -20,6 +20,12 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) BOOL hidden; @property (nonatomic) double alpha; @property (nonatomic, copy, nullable) NSString *identifier; +@property (nonatomic, copy, nullable) NSString *text; +@property (nonatomic, copy, nullable) NSString *axRole; +@property (nonatomic, copy) NSArray *superclasses; +@property (nonatomic) NSInteger constraintsCount; +@property (nonatomic) NSInteger childCount; +@property (nonatomic) BOOL truncated; @property (nonatomic) BOOL swiftUIBoundary; @property (nonatomic, copy, nullable) NSString *material; @property (nonatomic, copy, nullable) NSString *blendingMode; diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h index bd2eee3391..77f8b6bd17 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h @@ -27,11 +27,21 @@ NS_ASSUME_NONNULL_BEGIN /// with its contentView subtree. The rooted entry point for a full app walk. + (NSArray *)snapshotApplicationWindows; -/// Recursively snapshot `view` and its subtree. Frames are normalized against -/// `window`'s full frame (titlebar included); pass the view's window. When `window` -/// is nil, `frameTopLeft` falls back to the raw frame. +/// As above, bounded to `maxDepth` levels below each window's contentView. Nodes +/// at the bound report `truncated` + `childCount` with `children` omitted. ++ (NSArray *)snapshotApplicationWindowsWithMaxDepth:(NSInteger)maxDepth; + +/// Recursively snapshot `view` and its subtree (unbounded depth). Frames are +/// normalized against `window`'s full frame (titlebar included); pass the view's +/// window. When `window` is nil, `frameTopLeft` falls back to the raw frame. + (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view inWindow:(nullable NSWindow *)window; +/// As above, bounded to `maxDepth` levels below `view`. A node at the bound with +/// subviews reports `truncated == YES` + `childCount` and omits `children`. ++ (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view + inWindow:(nullable NSWindow *)window + maxDepth:(NSInteger)maxDepth; + @end NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m index 8616ba6fde..0271d0938c 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m @@ -16,6 +16,13 @@ #import #import +@interface FLEXAppKitWalker () ++ (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view + inWindow:(nullable NSWindow *)window + depth:(NSInteger)depth + maxDepth:(NSInteger)maxDepth; +@end + /// True if the view's class chain contains an NSHostingView (SwiftUI's host). The /// generic NSHostingView has a mangled Swift name, so match by substring /// across the hierarchy rather than isKindOfClass against a single concrete class. @@ -29,6 +36,31 @@ static BOOL FLEXIsSwiftUIBoundary(NSView *view) { return NO; } +/// Class hierarchy from the immediate superclass up to (and including) NSObject. +static NSArray *FLEXSuperclassNames(NSView *view) { + NSMutableArray *names = [NSMutableArray array]; + Class cls = class_getSuperclass(object_getClass(view)); + while (cls != Nil) { + [names addObject:NSStringFromClass(cls)]; + if (cls == [NSObject class]) { + break; + } + cls = class_getSuperclass(cls); + } + return names; +} + +/// Displayed text for the text-bearing view bases; nil otherwise. +static NSString *FLEXTextForView(NSView *view) { + NSString *text = nil; + if ([view isKindOfClass:[NSControl class]]) { + text = [(NSControl *)view stringValue]; + } else if ([view isKindOfClass:[NSText class]]) { + text = [(NSText *)view string]; + } + return text.length > 0 ? text : nil; +} + static NSString *FLEXMaterialName(NSVisualEffectMaterial material) { switch (material) { case NSVisualEffectMaterialTitlebar: return @"titlebar"; @@ -60,6 +92,10 @@ static BOOL FLEXIsSwiftUIBoundary(NSView *view) { @implementation FLEXAppKitWalker + (NSArray *)snapshotApplicationWindows { + return [self snapshotApplicationWindowsWithMaxDepth:NSIntegerMax]; +} + ++ (NSArray *)snapshotApplicationWindowsWithMaxDepth:(NSInteger)maxDepth { NSApplication *app = NSApplication.sharedApplication; NSWindow *keyWindow = app.keyWindow; NSWindow *mainWindow = app.mainWindow; @@ -76,13 +112,30 @@ @implementation FLEXAppKitWalker snapshot.isPanel = [window isKindOfClass:[NSPanel class]]; snapshot.frame = window.frame; NSView *content = window.contentView; - snapshot.contentView = content ? [self snapshotForView:content inWindow:window] : nil; + snapshot.contentView = content ? [self snapshotForView:content + inWindow:window + depth:0 + maxDepth:maxDepth] + : nil; [result addObject:snapshot]; } return result; } + (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view inWindow:(nullable NSWindow *)window { + return [self snapshotForView:view inWindow:window depth:0 maxDepth:NSIntegerMax]; +} + ++ (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view + inWindow:(nullable NSWindow *)window + maxDepth:(NSInteger)maxDepth { + return [self snapshotForView:view inWindow:window depth:0 maxDepth:maxDepth]; +} + ++ (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view + inWindow:(nullable NSWindow *)window + depth:(NSInteger)depth + maxDepth:(NSInteger)maxDepth { FLEXAppKitViewSnapshot *snapshot = [FLEXAppKitViewSnapshot new]; snapshot.className = NSStringFromClass(object_getClass(view)); snapshot.frame = view.frame; @@ -91,6 +144,10 @@ + (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view inWindow:(nullable NS snapshot.hidden = view.isHidden; snapshot.alpha = view.alphaValue; snapshot.identifier = view.identifier; + snapshot.text = FLEXTextForView(view); + snapshot.axRole = view.accessibilityRole; + snapshot.superclasses = FLEXSuperclassNames(view); + snapshot.constraintsCount = (NSInteger)view.constraints.count; snapshot.swiftUIBoundary = FLEXIsSwiftUIBoundary(view); snapshot.font = [FLEXAppKitFont fontForObject:view]; @@ -106,12 +163,23 @@ + (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view inWindow:(nullable NS inAppearance:view.effectiveAppearance]; } - NSMutableArray *children = - [NSMutableArray arrayWithCapacity:view.subviews.count]; - for (NSView *subview in view.subviews) { - [children addObject:[self snapshotForView:subview inWindow:window]]; + NSArray *subviews = view.subviews; + snapshot.childCount = (NSInteger)subviews.count; + if (subviews.count > 0 && depth >= maxDepth) { + // Depth bound reached: omit children but record how many were pruned. + snapshot.truncated = YES; + snapshot.children = @[]; + } else { + NSMutableArray *children = + [NSMutableArray arrayWithCapacity:subviews.count]; + for (NSView *subview in subviews) { + [children addObject:[self snapshotForView:subview + inWindow:window + depth:depth + 1 + maxDepth:maxDepth]]; + } + snapshot.children = children; } - snapshot.children = children; return snapshot; } diff --git a/DevProbe/main.m b/DevProbe/main.m index 88881c1d0f..933518ace8 100644 --- a/DevProbe/main.m +++ b/DevProbe/main.m @@ -138,6 +138,41 @@ int main(void) { check(vs.blendingMode.length > 0, [NSString stringWithFormat:@"blendingMode present (got %@)", vs.blendingMode]); + // 8. Node-schema completeness: superclasses / text / axRole / constraintsCount + check([cs.superclasses containsObject:@"NSView"] && [cs.superclasses.lastObject isEqualToString:@"NSObject"], + [NSString stringWithFormat:@"superclasses run up to NSObject (got %@)", cs.superclasses]); + check(ls.text != nil && [ls.text isEqualToString:@"Hi"], + [NSString stringWithFormat:@"text == 'Hi' for the label (got %@)", ls.text]); + check(ps.text == nil, @"plain NSView reports no text (null)"); + check(ls.axRole.length > 0, + [NSString stringWithFormat:@"label carries an axRole (got %@)", ls.axRole]); + + NSView *constrained = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 40, 40)]; + [content addSubview:constrained]; + [constrained addConstraint:[NSLayoutConstraint constraintWithItem:constrained + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:nil + attribute:NSLayoutAttributeNotAnAttribute + multiplier:1 + constant:120]]; + FLEXAppKitViewSnapshot *cns = [FLEXAppKitWalker snapshotForView:constrained inWindow:window]; + check(cns.constraintsCount == 1, + [NSString stringWithFormat:@"constraintsCount == 1 (got %ld)", (long)cns.constraintsCount]); + + // 9. Depth bound: truncated + childCount, children omitted past the bound + NSView *a = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 30, 30)]; + NSView *b = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 20, 20)]; + NSView *cc = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]; + [a addSubview:b]; + [b addSubview:cc]; + FLEXAppKitViewSnapshot *as = [FLEXAppKitWalker snapshotForView:a inWindow:nil maxDepth:1]; + check(as.truncated == NO && as.childCount == 1 && as.children.count == 1, + @"depth root: not truncated, childCount 1, one child present"); + FLEXAppKitViewSnapshot *deepB = as.children.firstObject; + check(deepB != nil && deepB.truncated == YES && deepB.childCount == 1 && deepB.children.count == 0, + @"depth bound: node truncated, childCount 1, children omitted"); + printf("\n%s (%d failure%s)\n", gFailures == 0 ? "ALL PASS" : "FAILURES", gFailures, gFailures == 1 ? "" : "s"); return gFailures == 0 ? 0 : 1; From 02c9274f7cf47aa953a1287125124f02dda5d027 Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Fri, 5 Jun 2026 22:54:13 -0500 Subject: [PATCH 4/7] feat: add constraint extraction and harden the AppKit walker 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). --- .../ViewHierarchy/AppKit/FLEXAppKitColor.h | 18 +- .../ViewHierarchy/AppKit/FLEXAppKitColor.m | 50 +++-- .../ViewHierarchy/AppKit/FLEXAppKitLayer.h | 5 + .../ViewHierarchy/AppKit/FLEXAppKitLayer.m | 30 ++- .../ViewHierarchy/AppKit/FLEXAppKitWalker.m | 83 +++++++-- .../AppKit/FLEXAppKitWindowSnapshot.h | 4 + .../FLEXAppKitWindowSnapshot_Internal.h | 1 + .../ViewHierarchy/AppKit/FLEXConstraintNode.h | 65 +++++++ .../ViewHierarchy/AppKit/FLEXConstraintNode.m | 173 ++++++++++++++++++ DevProbe/main.m | 61 ++++++ 10 files changed, 447 insertions(+), 43 deletions(-) create mode 100644 Classes/ViewHierarchy/AppKit/FLEXConstraintNode.h create mode 100644 Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h index c621abb58b..f5099bbb9f 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h @@ -2,11 +2,19 @@ // FLEXAppKitColor.h // FLEX // -// A resolved color fact: the unambiguous sRGB hex snapshot PLUS the catalog/ -// dynamic name where one is available (what a native reimplementation actually -// uses) PLUS the appearance context it was resolved under. Catalog/dynamic -// NSColors return nil or throw if components are read without first resolving -// through an appearance + a concrete color space — this type does that. +// A resolved color fact: the unambiguous sRGB hex snapshot, plus — for live +// NSColor inputs — the catalog/dynamic name where available (what a native +// reimplementation actually uses) and the appearance context it was resolved +// under. A live catalog/dynamic NSColor is resolved through an appearance + a +// concrete color space (otherwise reading components throws or yields the wrong +// appearance). +// +// CGColor inputs (e.g. a CALayer's backgroundColor) are already FLATTENED by the +// time the walker sees them: the dynamic/catalog identity was baked away when the +// view set the layer color, so for a CGColor only `hex` is populated (the baked +// sRGB value); `catalogName` and `appearanceName` are nil and `inAppearance:` is +// a no-op. Read view-level colors as NSColor (not via the layer) to recover the +// catalog name and appearance. // // SPEC: domain.walker // diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m index 128d7b6c62..366d9ba1a5 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m @@ -17,6 +17,19 @@ @interface FLEXAppKitColor () @property (nonatomic, copy, nullable) NSString *appearanceName; @end +/// sRGB hex of a color already converted to sRGB, or nil. The caller must convert +/// first — reading components on a non-RGB color throws. +static NSString *FLEXHexOfSRGBColor(NSColor *srgb) { + if (srgb == nil) { + return nil; + } + int r = (int)lround(srgb.redComponent * 255.0); + int g = (int)lround(srgb.greenComponent * 255.0); + int b = (int)lround(srgb.blueComponent * 255.0); + int a = (int)lround(srgb.alphaComponent * 255.0); + return [NSString stringWithFormat:@"#%02X%02X%02X%02X", r, g, b, a]; +} + @implementation FLEXAppKitColor + (nullable FLEXAppKitColor *)colorFromColor:(nullable id)input @@ -25,16 +38,26 @@ + (nullable FLEXAppKitColor *)colorFromColor:(nullable id)input return nil; } - NSColor *color = nil; - if ([input isKindOfClass:[NSColor class]]) { - color = input; - } else if (CFGetTypeID((__bridge CFTypeRef)input) == CGColorGetTypeID()) { - color = [NSColor colorWithCGColor:(__bridge CGColorRef)input]; + // CGColor (e.g. a layer's backgroundColor): already a flat, baked color. It + // cannot carry a catalog name and is NOT re-resolvable to a different + // appearance — the dynamic identity was lost when the view baked it into the + // layer. Capture the baked sRGB hex as-is; catalogName/appearanceName stay nil. + if (![input isKindOfClass:[NSColor class]] + && CFGetTypeID((__bridge CFTypeRef)input) == CGColorGetTypeID()) { + NSColor *flat = [NSColor colorWithCGColor:(__bridge CGColorRef)input]; + NSString *hex = FLEXHexOfSRGBColor([flat colorUsingColorSpace:[NSColorSpace sRGBColorSpace]]); + if (hex == nil) { + return nil; // unconvertible (e.g. a pattern color) — no misleading value + } + FLEXAppKitColor *result = [FLEXAppKitColor new]; + result.hex = hex; + return result; } - if (color == nil) { + + if (![input isKindOfClass:[NSColor class]]) { return nil; } - + NSColor *color = input; FLEXAppKitColor *result = [FLEXAppKitColor new]; // Catalog/dynamic NAME, only where the color genuinely is a catalog color @@ -48,8 +71,8 @@ + (nullable FLEXAppKitColor *)colorFromColor:(nullable id)input NSAppearance *resolveAppearance = [appearance isKindOfClass:[NSAppearance class]] ? appearance : nil; result.appearanceName = resolveAppearance.name; - // Resolve to sRGB components UNDER the appearance — required for catalog/dynamic - // colors, which otherwise return nil or throw. + // Resolve to sRGB UNDER the appearance — required for a live catalog/dynamic + // NSColor, which otherwise returns nil or resolves under the wrong appearance. __block NSColor *resolved = nil; void (^toSRGB)(void) = ^{ resolved = [color colorUsingColorSpace:[NSColorSpace sRGBColorSpace]]; @@ -64,14 +87,7 @@ + (nullable FLEXAppKitColor *)colorFromColor:(nullable id)input toSRGB(); } - if (resolved != nil) { - int r = (int)lround(resolved.redComponent * 255.0); - int g = (int)lround(resolved.greenComponent * 255.0); - int b = (int)lround(resolved.blueComponent * 255.0); - int a = (int)lround(resolved.alphaComponent * 255.0); - result.hex = [NSString stringWithFormat:@"#%02X%02X%02X%02X", r, g, b, a]; - } - + result.hex = FLEXHexOfSRGBColor(resolved); return result; } diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h index 3b330f71c6..5e134f00c2 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h @@ -34,6 +34,11 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly, nullable) FLEXAppKitColor *shadowColor; /// The parallel sublayer tree, including standalone sublayers backing no view. @property (nonatomic, readonly, copy) NSArray *sublayers; +/// Number of direct sublayers, always reported even when `sublayers` is truncated. +@property (nonatomic, readonly) NSInteger sublayerCount; +/// True when sublayers were omitted because the depth bound was reached — guards +/// against pathological deep layer trees (CATiledLayer/WebKit/Metal) blowing the stack. +@property (nonatomic, readonly) BOOL truncated; @end diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m index b08c0780d1..a8cb174030 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m @@ -25,11 +25,24 @@ @interface FLEXAppKitLayer () @property (nonatomic) CGSize shadowOffset; @property (nonatomic, nullable) FLEXAppKitColor *shadowColor; @property (nonatomic, copy) NSArray *sublayers; +@property (nonatomic) NSInteger sublayerCount; +@property (nonatomic) BOOL truncated; @end +/// CALayer trees are normally shallow, but pathological backing (CATiledLayer +/// pyramids, WebKit compositing, Metal/AVPlayer stacks) can be deep; cap recursion +/// so a walk can never overflow the stack on a hostile tree. +static const NSInteger kFLEXMaxLayerDepth = 64; + @implementation FLEXAppKitLayer + (instancetype)layerFromLayer:(CALayer *)layer inAppearance:(nullable id)appearance { + return [self layerFromLayer:layer inAppearance:appearance depth:0]; +} + ++ (instancetype)layerFromLayer:(CALayer *)layer + inAppearance:(nullable id)appearance + depth:(NSInteger)depth { FLEXAppKitLayer *result = [FLEXAppKitLayer new]; result.className = NSStringFromClass(object_getClass(layer)); result.cornerRadius = layer.cornerRadius; @@ -46,12 +59,19 @@ + (instancetype)layerFromLayer:(CALayer *)layer inAppearance:(nullable id)appear result.shadowColor = [FLEXAppKitColor colorFromColor:(__bridge id)layer.shadowColor inAppearance:appearance]; - NSMutableArray *subs = - [NSMutableArray arrayWithCapacity:layer.sublayers.count]; - for (CALayer *sub in layer.sublayers) { - [subs addObject:[self layerFromLayer:sub inAppearance:appearance]]; + NSArray *sublayers = layer.sublayers; + result.sublayerCount = (NSInteger)sublayers.count; + if (sublayers.count > 0 && depth >= kFLEXMaxLayerDepth) { + result.truncated = YES; + result.sublayers = @[]; + } else { + NSMutableArray *subs = + [NSMutableArray arrayWithCapacity:sublayers.count]; + for (CALayer *sub in sublayers) { + [subs addObject:[self layerFromLayer:sub inAppearance:appearance depth:depth + 1]]; + } + result.sublayers = subs; } - result.sublayers = subs; return result; } diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m index 0271d0938c..37a96b6994 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m @@ -100,28 +100,79 @@ @implementation FLEXAppKitWalker NSWindow *keyWindow = app.keyWindow; NSWindow *mainWindow = app.mainWindow; + // Only top-level windows are roots: a window attached as a sheet or held as a + // child window is nested under its parent, not emitted as a separate root. + NSMutableSet *visited = [NSMutableSet set]; NSMutableArray *result = [NSMutableArray array]; for (NSWindow *window in app.windows) { - FLEXAppKitWindowSnapshot *snapshot = [FLEXAppKitWindowSnapshot new]; - snapshot.className = NSStringFromClass(object_getClass(window)); - snapshot.title = window.title; - snapshot.identifier = window.identifier; - snapshot.isKeyWindow = (window == keyWindow); - snapshot.isMainWindow = (window == mainWindow); - snapshot.isVisible = window.isVisible; - snapshot.isPanel = [window isKindOfClass:[NSPanel class]]; - snapshot.frame = window.frame; - NSView *content = window.contentView; - snapshot.contentView = content ? [self snapshotForView:content - inWindow:window - depth:0 - maxDepth:maxDepth] - : nil; - [result addObject:snapshot]; + if (window.parentWindow != nil || window.sheetParent != nil) { + continue; + } + FLEXAppKitWindowSnapshot *snapshot = [self windowSnapshotFor:window + key:keyWindow + main:mainWindow + maxDepth:maxDepth + visited:visited]; + if (snapshot != nil) { + [result addObject:snapshot]; + } } return result; } ++ (nullable FLEXAppKitWindowSnapshot *)windowSnapshotFor:(NSWindow *)window + key:(nullable NSWindow *)keyWindow + main:(nullable NSWindow *)mainWindow + maxDepth:(NSInteger)maxDepth + visited:(NSMutableSet *)visited { + NSValue *box = [NSValue valueWithNonretainedObject:window]; + if ([visited containsObject:box]) { + return nil; // guard against a window appearing in two relationships + } + [visited addObject:box]; + + FLEXAppKitWindowSnapshot *snapshot = [FLEXAppKitWindowSnapshot new]; + snapshot.className = NSStringFromClass(object_getClass(window)); + snapshot.title = window.title; + snapshot.identifier = window.identifier; + snapshot.isKeyWindow = (window == keyWindow); + snapshot.isMainWindow = (window == mainWindow); + snapshot.isVisible = window.isVisible; + snapshot.isPanel = [window isKindOfClass:[NSPanel class]]; + snapshot.frame = window.frame; + NSView *content = window.contentView; + snapshot.contentView = content ? [self snapshotForView:content + inWindow:window + depth:0 + maxDepth:maxDepth] + : nil; + + NSMutableArray *children = [NSMutableArray array]; + for (NSWindow *child in window.childWindows) { + FLEXAppKitWindowSnapshot *childSnapshot = [self windowSnapshotFor:child + key:keyWindow + main:mainWindow + maxDepth:maxDepth + visited:visited]; + if (childSnapshot != nil) { + [children addObject:childSnapshot]; + } + } + NSWindow *sheet = window.attachedSheet; + if (sheet != nil) { + FLEXAppKitWindowSnapshot *sheetSnapshot = [self windowSnapshotFor:sheet + key:keyWindow + main:mainWindow + maxDepth:maxDepth + visited:visited]; + if (sheetSnapshot != nil) { + [children addObject:sheetSnapshot]; + } + } + snapshot.childWindows = children; + return snapshot; +} + + (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view inWindow:(nullable NSWindow *)window { return [self snapshotForView:view inWindow:window depth:0 maxDepth:NSIntegerMax]; } diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h index 2c6d420d5b..547d2f0da2 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h @@ -30,6 +30,10 @@ NS_ASSUME_NONNULL_BEGIN /// Snapshot of the window's contentView subtree; nil if there is no contentView. @property (nonatomic, readonly, nullable) FLEXAppKitViewSnapshot *contentView; +/// Transient/child windows nested under this one — attached sheets, child windows +/// (NSPopover content, panels). Only top-level windows are roots; these are not. +@property (nonatomic, readonly, copy) NSArray *childWindows; + @end NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h index e72432b3c9..614812b8b9 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h @@ -21,6 +21,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) BOOL isPanel; @property (nonatomic) CGRect frame; @property (nonatomic, nullable) FLEXAppKitViewSnapshot *contentView; +@property (nonatomic, copy) NSArray *childWindows; @end NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.h b/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.h new file mode 100644 index 0000000000..099a26fcbd --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.h @@ -0,0 +1,65 @@ +// +// FLEXConstraintNode.h +// FLEX +// +// Auto Layout extraction for one view: every NSLayoutConstraint touching it in +// BOTH directions (where it is the first item and the second item), serialized as +// first.attr (relation) second.attr * multiplier + constant @ priority, plus the +// node's intrinsic-sizing facts. NSLayoutConstraint is the same class on macOS and +// iOS, so this is cross-platform. +// +// Node-id stringification of each item is the server's concern; this captures the +// AppKit facts + each item's class and role (target / view / layoutGuide / none). +// +// SPEC: domain.walker +// + +#import +#import + +@class NSView; + +NS_ASSUME_NONNULL_BEGIN + +/// One side of a constraint. +@interface FLEXConstraintItem : NSObject +/// Runtime class of the item; nil for the absent second item of a constant constraint. +@property (nonatomic, readonly, copy, nullable) NSString *className; +/// "leading" / "width" / "notAnAttribute" ... +@property (nonatomic, readonly, copy) NSString *attribute; +/// "view" | "layoutGuide" | "other" | "none" +@property (nonatomic, readonly, copy) NSString *kind; +/// True when this item is the view the FLEXConstraintNode describes. +@property (nonatomic, readonly) BOOL isTarget; +@end + +@interface FLEXConstraint : NSObject +@property (nonatomic, readonly) FLEXConstraintItem *first; +@property (nonatomic, readonly, copy) NSString *relation; // "lessThanOrEqual"/"equal"/"greaterThanOrEqual" +@property (nonatomic, readonly) FLEXConstraintItem *second; +@property (nonatomic, readonly) double multiplier; +@property (nonatomic, readonly) double constant; +@property (nonatomic, readonly) double priority; +@property (nonatomic, readonly) BOOL active; +@property (nonatomic, readonly, copy, nullable) NSString *identifier; +@end + +@interface FLEXConstraintNode : NSObject + +/// Extract the constraints touching `view` in both directions, plus its +/// intrinsic-sizing facts. Must be called on the main thread. ++ (instancetype)constraintsForView:(NSView *)view; + +@property (nonatomic, readonly) BOOL translatesAutoresizingMaskIntoConstraints; +/// Raw intrinsicContentSize; an axis with no intrinsic metric is NSViewNoIntrinsicMetric (-1). +@property (nonatomic, readonly) CGSize intrinsicContentSize; +@property (nonatomic, readonly) double huggingHorizontal; +@property (nonatomic, readonly) double huggingVertical; +@property (nonatomic, readonly) double compressionResistanceHorizontal; +@property (nonatomic, readonly) double compressionResistanceVertical; +/// The constraints touching the view, both directions, deduplicated. +@property (nonatomic, readonly, copy) NSArray *constraints; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m b/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m new file mode 100644 index 0000000000..035e9ffb5d --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m @@ -0,0 +1,173 @@ +// +// FLEXConstraintNode.m +// FLEX +// +// SPEC: domain.walker +// + +#import "FLEXConstraintNode.h" + +#if TARGET_OS_OSX + +#import +#import + +static NSString *FLEXAttrName(NSLayoutAttribute attr) { + switch (attr) { + case NSLayoutAttributeLeft: return @"left"; + case NSLayoutAttributeRight: return @"right"; + case NSLayoutAttributeTop: return @"top"; + case NSLayoutAttributeBottom: return @"bottom"; + case NSLayoutAttributeLeading: return @"leading"; + case NSLayoutAttributeTrailing: return @"trailing"; + case NSLayoutAttributeWidth: return @"width"; + case NSLayoutAttributeHeight: return @"height"; + case NSLayoutAttributeCenterX: return @"centerX"; + case NSLayoutAttributeCenterY: return @"centerY"; + case NSLayoutAttributeLastBaseline: return @"lastBaseline"; + case NSLayoutAttributeFirstBaseline: return @"firstBaseline"; + case NSLayoutAttributeNotAnAttribute: return @"notAnAttribute"; + default: return [NSString stringWithFormat:@"attr(%ld)", (long)attr]; + } +} + +static NSString *FLEXRelationName(NSLayoutRelation relation) { + switch (relation) { + case NSLayoutRelationLessThanOrEqual: return @"lessThanOrEqual"; + case NSLayoutRelationEqual: return @"equal"; + case NSLayoutRelationGreaterThanOrEqual: return @"greaterThanOrEqual"; + default: return [NSString stringWithFormat:@"relation(%ld)", (long)relation]; + } +} + +#pragma mark - + +@interface FLEXConstraintItem () +@property (nonatomic, copy, nullable) NSString *className; +@property (nonatomic, copy) NSString *attribute; +@property (nonatomic, copy) NSString *kind; +@property (nonatomic) BOOL isTarget; +@end + +@implementation FLEXConstraintItem + ++ (FLEXConstraintItem *)itemFor:(nullable id)item + attribute:(NSLayoutAttribute)attribute + target:(NSView *)target { + FLEXConstraintItem *result = [FLEXConstraintItem new]; + result.attribute = FLEXAttrName(attribute); + if (item == nil) { + result.kind = @"none"; + return result; + } + result.className = NSStringFromClass(object_getClass(item)); + result.isTarget = (item == target); + if ([item isKindOfClass:[NSView class]]) { + result.kind = @"view"; + } else if ([item isKindOfClass:[NSLayoutGuide class]]) { + result.kind = @"layoutGuide"; + } else { + result.kind = @"other"; + } + return result; +} + +@end + +#pragma mark - + +@interface FLEXConstraint () +@property (nonatomic) FLEXConstraintItem *first; +@property (nonatomic, copy) NSString *relation; +@property (nonatomic) FLEXConstraintItem *second; +@property (nonatomic) double multiplier; +@property (nonatomic) double constant; +@property (nonatomic) double priority; +@property (nonatomic) BOOL active; +@property (nonatomic, copy, nullable) NSString *identifier; +@end + +@implementation FLEXConstraint + ++ (FLEXConstraint *)constraintFrom:(NSLayoutConstraint *)constraint target:(NSView *)target { + FLEXConstraint *result = [FLEXConstraint new]; + result.first = [FLEXConstraintItem itemFor:constraint.firstItem + attribute:constraint.firstAttribute + target:target]; + result.relation = FLEXRelationName(constraint.relation); + result.second = [FLEXConstraintItem itemFor:constraint.secondItem + attribute:constraint.secondAttribute + target:target]; + result.multiplier = constraint.multiplier; + result.constant = constraint.constant; + result.priority = constraint.priority; + result.active = constraint.isActive; + result.identifier = constraint.identifier; + return result; +} + +@end + +#pragma mark - + +@interface FLEXConstraintNode () +@property (nonatomic) BOOL translatesAutoresizingMaskIntoConstraints; +@property (nonatomic) CGSize intrinsicContentSize; +@property (nonatomic) double huggingHorizontal; +@property (nonatomic) double huggingVertical; +@property (nonatomic) double compressionResistanceHorizontal; +@property (nonatomic) double compressionResistanceVertical; +@property (nonatomic, copy) NSArray *constraints; +@end + +@implementation FLEXConstraintNode + ++ (instancetype)constraintsForView:(NSView *)view { + FLEXConstraintNode *node = [FLEXConstraintNode new]; + node.translatesAutoresizingMaskIntoConstraints = view.translatesAutoresizingMaskIntoConstraints; + node.intrinsicContentSize = view.intrinsicContentSize; + node.huggingHorizontal = + [view contentHuggingPriorityForOrientation:NSLayoutConstraintOrientationHorizontal]; + node.huggingVertical = + [view contentHuggingPriorityForOrientation:NSLayoutConstraintOrientationVertical]; + node.compressionResistanceHorizontal = + [view contentCompressionResistancePriorityForOrientation:NSLayoutConstraintOrientationHorizontal]; + node.compressionResistanceVertical = + [view contentCompressionResistancePriorityForOrientation:NSLayoutConstraintOrientationVertical]; + + NSMutableArray *out = [NSMutableArray array]; + NSMutableSet *seen = [NSMutableSet set]; + + void (^collect)(NSArray *, BOOL) = + ^(NSArray *constraints, BOOL requireTouch) { + for (NSLayoutConstraint *constraint in constraints) { + if (![constraint isKindOfClass:[NSLayoutConstraint class]]) { + continue; + } + if (requireTouch + && constraint.firstItem != view + && constraint.secondItem != view) { + continue; + } + NSValue *box = [NSValue valueWithNonretainedObject:constraint]; + if ([seen containsObject:box]) { + continue; + } + [seen addObject:box]; + [out addObject:[FLEXConstraint constraintFrom:constraint target:view]]; + } + }; + + // Forward: the view's own constraints. Then both directions: any ancestor-held + // constraint that references this view (AppKit has no public reverse index). + collect(view.constraints, NO); + for (NSView *ancestor = view.superview; ancestor != nil; ancestor = ancestor.superview) { + collect(ancestor.constraints, YES); + } + node.constraints = out; + return node; +} + +@end + +#endif // TARGET_OS_OSX diff --git a/DevProbe/main.m b/DevProbe/main.m index 933518ace8..cde0e65b8e 100644 --- a/DevProbe/main.m +++ b/DevProbe/main.m @@ -173,6 +173,67 @@ int main(void) { check(deepB != nil && deepB.truncated == YES && deepB.childCount == 1 && deepB.children.count == 0, @"depth bound: node truncated, childCount 1, children omitted"); + // 10. Constraints extraction (FLEXConstraintNode) + FLEXConstraintNode *cn = [FLEXConstraintNode constraintsForView:constrained]; + FLEXConstraint *wc = cn.constraints.firstObject; + check(wc != nil && [wc.first.attribute isEqualToString:@"width"] + && [wc.relation isEqualToString:@"equal"] && approx(wc.constant, 120) + && [wc.second.kind isEqualToString:@"none"] && wc.first.isTarget, + @"width constraint serialized (width == 120, second none, first is target)"); + + NSView *cont = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 200, 50)]; + NSView *cA = [NSView new]; + NSView *cB = [NSView new]; + cA.translatesAutoresizingMaskIntoConstraints = NO; + cB.translatesAutoresizingMaskIntoConstraints = NO; + [cont addSubview:cA]; + [cont addSubview:cB]; + [cont addConstraint:[NSLayoutConstraint constraintWithItem:cA + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:cB + attribute:NSLayoutAttributeLeading + multiplier:1 + constant:8]]; + FLEXConstraintNode *cnA = [FLEXConstraintNode constraintsForView:cA]; + FLEXConstraint *bc = cnA.constraints.firstObject; + check(cnA.constraints.count == 1 && bc != nil && [bc.first.attribute isEqualToString:@"trailing"] + && bc.first.isTarget && [bc.second.kind isEqualToString:@"view"] && approx(bc.constant, 8), + @"sibling constraint found for the first item (ancestor-held, both directions)"); + FLEXConstraintNode *cnB = [FLEXConstraintNode constraintsForView:cB]; + check(cnB.constraints.count == 1, @"same constraint found for the SECOND item (reverse direction)"); + + // 11. Window nesting: a child window nests under its parent, not as a root + NSWindow *childWin = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 100, 100) + styleMask:NSWindowStyleMaskTitled + backing:NSBackingStoreBuffered + defer:NO]; + childWin.title = @"ChildProbeWindow"; + [window addChildWindow:childWin ordered:NSWindowAbove]; + NSArray *ws2 = [FLEXAppKitWalker snapshotApplicationWindows]; + BOOL childIsRoot = NO; + FLEXAppKitWindowSnapshot *parentRoot = nil; + for (FLEXAppKitWindowSnapshot *w in ws2) { + if ([w.title isEqualToString:@"ChildProbeWindow"]) { childIsRoot = YES; } + if ([w.title isEqualToString:@"ProbeWindow"]) { parentRoot = w; } + } + check(!childIsRoot, @"child window is NOT a top-level root"); + BOOL childNested = NO; + for (FLEXAppKitWindowSnapshot *c in parentRoot.childWindows) { + if ([c.title isEqualToString:@"ChildProbeWindow"]) { childNested = YES; } + } + check(parentRoot != nil && childNested, @"child window nested under its parent window"); + + // 12. Parallel CALayer sublayer tree + count + NSView *layerHost = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 30, 30)]; + layerHost.wantsLayer = YES; + [layerHost.layer addSublayer:[CALayer layer]]; + [layerHost.layer addSublayer:[CALayer layer]]; + FLEXAppKitViewSnapshot *lhs = [FLEXAppKitWalker snapshotForView:layerHost inWindow:nil]; + check(lhs.layer != nil && lhs.layer.sublayerCount == 2 && lhs.layer.sublayers.count == 2 && !lhs.layer.truncated, + [NSString stringWithFormat:@"parallel layer tree has 2 sublayers (got %ld)", + lhs.layer ? (long)lhs.layer.sublayerCount : -1]); + printf("\n%s (%d failure%s)\n", gFailures == 0 ? "ALL PASS" : "FAILURES", gFailures, gFailures == 1 ? "" : "s"); return gFailures == 0 ? 0 : 1; From 04d3c5c220742d6747ef8fd7dff06ea31dd4c81f Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Fri, 5 Jun 2026 22:55:50 -0500 Subject: [PATCH 5/7] feat: add hitTest-at-a-point to the AppKit walker 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. --- Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h | 6 ++++++ Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m | 13 +++++++++++++ DevProbe/main.m | 12 ++++++++++++ 3 files changed, 31 insertions(+) diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h index 77f8b6bd17..5a9cf662e7 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h @@ -42,6 +42,12 @@ NS_ASSUME_NONNULL_BEGIN inWindow:(nullable NSWindow *)window maxDepth:(NSInteger)maxDepth; +/// The deepest view at `point` (window base coordinates, bottom-left origin), +/// snapshotted as a single node with its children omitted. The macOS substitute +/// for touch hit-testing. Returns nil if nothing is hit. ++ (nullable FLEXAppKitViewSnapshot *)snapshotForHitTestAtPoint:(CGPoint)point + inWindow:(NSWindow *)window; + @end NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m index 37a96b6994..fb90fdae2b 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m @@ -234,6 +234,19 @@ + (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view return snapshot; } ++ (nullable FLEXAppKitViewSnapshot *)snapshotForHitTestAtPoint:(CGPoint)point + inWindow:(NSWindow *)window { + // hitTest: wants the point in the receiver's superview coordinates; the window's + // root view (the border/theme view) has the window base coordinate system, so a + // window-base point is correct for it. + NSView *root = window.contentView.superview ?: window.contentView; + NSView *hit = [root hitTest:point]; + if (hit == nil) { + return nil; + } + return [self snapshotForView:hit inWindow:window maxDepth:0]; +} + /// Normalized top-left rect, full-window-frame-relative (titlebar included), per /// domain.walker. Computed through screen coordinates so per-view isFlipped is /// resolved by AppKit's own conversion rather than manual y-flipping. diff --git a/DevProbe/main.m b/DevProbe/main.m index cde0e65b8e..a9521d7c8d 100644 --- a/DevProbe/main.m +++ b/DevProbe/main.m @@ -234,6 +234,18 @@ int main(void) { [NSString stringWithFormat:@"parallel layer tree has 2 sublayers (got %ld)", lhs.layer ? (long)lhs.layer.sublayerCount : -1]); + // 13. hitTest at a point + NSWindow *hitWindow = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 200, 200) + styleMask:NSWindowStyleMaskTitled + backing:NSBackingStoreBuffered + defer:NO]; + FLEXProbeView *htv = [[FLEXProbeView alloc] initWithFrame:NSMakeRect(10, 10, 50, 50)]; + [hitWindow.contentView addSubview:htv]; + FLEXAppKitViewSnapshot *hitSnap = [FLEXAppKitWalker snapshotForHitTestAtPoint:NSMakePoint(20, 20) + inWindow:hitWindow]; + check(hitSnap != nil && [hitSnap.className isEqualToString:@"FLEXProbeView"], + [NSString stringWithFormat:@"hitTest at (20,20) hits FLEXProbeView (got %@)", hitSnap.className]); + printf("\n%s (%d failure%s)\n", gFailures == 0 ? "ALL PASS" : "FAILURES", gFailures, gFailures == 1 ? "" : "s"); return gFailures == 0 ? 0 : 1; From 8387596ef7396e86eabc3082059fc4b9ff3bad5e Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Fri, 5 Jun 2026 23:14:46 -0500 Subject: [PATCH 6/7] feat: add JSON projection + a self-hosting SampleAppKitDump 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. --- Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.h | 32 ++++ Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.m | 162 ++++++++++++++++++ .../ViewHierarchy/AppKit/FLEXConstraintNode.m | 9 +- Package.swift | 5 + Samples/SampleAppKitDump/main.m | 101 +++++++++++ 5 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.h create mode 100644 Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.m create mode 100644 Samples/SampleAppKitDump/main.m diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.h new file mode 100644 index 0000000000..10eeb715bc --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.h @@ -0,0 +1,32 @@ +// +// FLEXAppKitJSON.h +// FLEX +// +// Projects the walker's snapshot model into JSON-serializable Foundation +// dictionaries shaped per the flexscope node schema (domain.node). Floats are +// emitted at fixed precision and nils as NSNull so the output is deterministic +// and round-trips through NSJSONSerialization. The final string serialization is +// the consumer's job (ARCHITECTURE §5.3: the FLEX side returns Foundation +// collections; the server/CLI serializes). +// +// SPEC: domain.walker +// + +#import + +@class FLEXAppKitViewSnapshot; +@class FLEXAppKitWindowSnapshot; +@class FLEXConstraintNode; + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitJSON : NSObject + ++ (NSArray *)dictionariesForWindows:(NSArray *)windows; ++ (NSDictionary *)dictionaryForWindow:(FLEXAppKitWindowSnapshot *)window; ++ (NSDictionary *)dictionaryForView:(FLEXAppKitViewSnapshot *)view; ++ (NSDictionary *)dictionaryForConstraintNode:(FLEXConstraintNode *)node; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.m new file mode 100644 index 0000000000..226d2dd81b --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.m @@ -0,0 +1,162 @@ +// +// FLEXAppKitJSON.m +// FLEX +// +// SPEC: domain.walker +// + +#import "FLEXAppKitJSON.h" + +#if TARGET_OS_OSX + +#import "FLEXAppKitViewSnapshot.h" +#import "FLEXAppKitWindowSnapshot.h" +#import "FLEXAppKitFont.h" +#import "FLEXAppKitLayer.h" +#import "FLEXAppKitColor.h" +#import "FLEXConstraintNode.h" + +static id orNull(id _Nullable value) { + return value ?: [NSNull null]; +} + +/// Fixed precision (1 dp) for diffable, deterministic output. +static NSNumber *num1(double value) { + return @(round(value * 10.0) / 10.0); +} + +static NSDictionary *rectDict(CGRect r) { + return @{ @"x": num1(r.origin.x), @"y": num1(r.origin.y), + @"w": num1(r.size.width), @"h": num1(r.size.height) }; +} + +@implementation FLEXAppKitJSON + ++ (id)colorDict:(FLEXAppKitColor *)color { + if (color == nil) { + return [NSNull null]; + } + return @{ @"hex": orNull(color.hex), + @"catalogName": orNull(color.catalogName), + @"appearanceName": orNull(color.appearanceName) }; +} + ++ (id)fontDict:(FLEXAppKitFont *)font { + if (font == nil) { + return [NSNull null]; + } + return @{ @"family": orNull(font.familyName), + @"size": num1(font.pointSize), + @"weightTrait": @(font.weightTrait), + @"weightName": orNull(font.weightName), + @"postScriptName": orNull(font.postScriptName), + @"traits": font.traits ?: @[] }; +} + ++ (id)layerDict:(FLEXAppKitLayer *)layer { + if (layer == nil) { + return [NSNull null]; + } + NSMutableArray *sublayers = [NSMutableArray array]; + for (FLEXAppKitLayer *sub in layer.sublayers) { + [sublayers addObject:[self layerDict:sub]]; + } + return @{ @"class": orNull(layer.className), + @"cornerRadius": num1(layer.cornerRadius), + @"masksToBounds": @(layer.masksToBounds), + @"opacity": num1(layer.opacity), + @"borderWidth": num1(layer.borderWidth), + @"backgroundColor": [self colorDict:layer.backgroundColor], + @"borderColor": [self colorDict:layer.borderColor], + @"shadowOpacity": num1(layer.shadowOpacity), + @"shadowRadius": num1(layer.shadowRadius), + @"shadowOffset": @{ @"w": num1(layer.shadowOffset.width), @"h": num1(layer.shadowOffset.height) }, + @"shadowColor": [self colorDict:layer.shadowColor], + @"sublayerCount": @(layer.sublayerCount), + @"truncated": @(layer.truncated), + @"sublayers": sublayers }; +} + ++ (NSDictionary *)dictionaryForView:(FLEXAppKitViewSnapshot *)view { + NSMutableArray *children = [NSMutableArray array]; + for (FLEXAppKitViewSnapshot *child in view.children) { + [children addObject:[self dictionaryForView:child]]; + } + return @{ @"class": orNull(view.className), + @"superclasses": view.superclasses ?: @[], + @"frame": rectDict(view.frame), + @"frameTopLeft": rectDict(view.frameTopLeft), + @"isFlipped": @(view.isFlipped), + @"hidden": @(view.hidden), + @"alpha": num1(view.alpha), + @"identifier": orNull(view.identifier), + @"text": orNull(view.text), + @"axRole": orNull(view.axRole), + @"font": [self fontDict:view.font], + @"material": orNull(view.material), + @"blendingMode": orNull(view.blendingMode), + @"layer": [self layerDict:view.layer], + @"constraintsCount": @(view.constraintsCount), + @"swiftUIBoundary": @(view.swiftUIBoundary), + @"childCount": @(view.childCount), + @"truncated": @(view.truncated), + @"children": children }; +} + ++ (NSDictionary *)dictionaryForWindow:(FLEXAppKitWindowSnapshot *)window { + NSMutableArray *childWindows = [NSMutableArray array]; + for (FLEXAppKitWindowSnapshot *child in window.childWindows) { + [childWindows addObject:[self dictionaryForWindow:child]]; + } + return @{ @"class": orNull(window.className), + @"title": orNull(window.title), + @"identifier": orNull(window.identifier), + @"isKeyWindow": @(window.isKeyWindow), + @"isMainWindow": @(window.isMainWindow), + @"isVisible": @(window.isVisible), + @"isPanel": @(window.isPanel), + @"frame": rectDict(window.frame), + @"contentView": window.contentView ? [self dictionaryForView:window.contentView] : [NSNull null], + @"childWindows": childWindows }; +} + ++ (NSArray *)dictionariesForWindows:(NSArray *)windows { + NSMutableArray *result = [NSMutableArray array]; + for (FLEXAppKitWindowSnapshot *window in windows) { + [result addObject:[self dictionaryForWindow:window]]; + } + return result; +} + ++ (id)constraintItemDict:(FLEXConstraintItem *)item { + return @{ @"class": orNull(item.className), + @"attribute": orNull(item.attribute), + @"kind": orNull(item.kind), + @"isTarget": @(item.isTarget) }; +} + ++ (NSDictionary *)dictionaryForConstraintNode:(FLEXConstraintNode *)node { + NSMutableArray *constraints = [NSMutableArray array]; + for (FLEXConstraint *c in node.constraints) { + [constraints addObject:@{ @"first": [self constraintItemDict:c.first], + @"relation": orNull(c.relation), + @"second": [self constraintItemDict:c.second], + @"multiplier": num1(c.multiplier), + @"constant": num1(c.constant), + @"priority": num1(c.priority), + @"active": @(c.active), + @"identifier": orNull(c.identifier) }]; + } + return @{ @"translatesAutoresizingMaskIntoConstraints": @(node.translatesAutoresizingMaskIntoConstraints), + @"intrinsicContentSize": @{ @"w": num1(node.intrinsicContentSize.width), + @"h": num1(node.intrinsicContentSize.height) }, + @"hugging": @{ @"horizontal": num1(node.huggingHorizontal), + @"vertical": num1(node.huggingVertical) }, + @"compressionResistance": @{ @"horizontal": num1(node.compressionResistanceHorizontal), + @"vertical": num1(node.compressionResistanceVertical) }, + @"constraints": constraints }; +} + +@end + +#endif // TARGET_OS_OSX diff --git a/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m b/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m index 035e9ffb5d..ec1ba381a0 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m +++ b/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m @@ -158,9 +158,12 @@ + (instancetype)constraintsForView:(NSView *)view { } }; - // Forward: the view's own constraints. Then both directions: any ancestor-held - // constraint that references this view (AppKit has no public reverse index). - collect(view.constraints, NO); + // Constraints that TOUCH this view (first or second item), in both directions: + // its own constraints that reference it, plus any ancestor-held constraint that + // references it (AppKit has no public reverse index). A view also holds + // constraints purely between its descendants — those don't touch it and are + // excluded, matching the spec's "constraints that touch it". + collect(view.constraints, YES); for (NSView *ancestor = view.superview; ancestor != nil; ancestor = ancestor.superview) { collect(ancestor.constraints, YES); } diff --git a/Package.swift b/Package.swift index 7a13db243c..60115b7895 100644 --- a/Package.swift +++ b/Package.swift @@ -57,6 +57,11 @@ let package = Package( dependencies: ["FLEXAppKit"], path: "DevProbe" ), + .target( + name: "SampleAppKitDump", + dependencies: ["FLEXAppKit"], + path: "Samples/SampleAppKitDump" + ), ], // Required to compile FLEXSwiftInternal.mm cxxLanguageStandard: .gnucxx11 diff --git a/Samples/SampleAppKitDump/main.m b/Samples/SampleAppKitDump/main.m new file mode 100644 index 0000000000..30523a2694 --- /dev/null +++ b/Samples/SampleAppKitDump/main.m @@ -0,0 +1,101 @@ +// +// main.m — SampleAppKitDump +// +// A self-hosting AppKit sample: builds a representative window (sidebar +// NSVisualEffectView, known fonts, a layer-backed accent row, Auto Layout +// constraints) and prints its OWN runtime view tree as JSON, produced by the real +// FLEXAppKitWalker. No injection, no SIP changes — run with: +// swift run SampleAppKitDump +// Eyeball the JSON against the constructed window to verify the AppKit surface. +// + +#import +#import +@import FLEXAppKit; + +int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + + NSWindow *window = + [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 640, 420) + styleMask:(NSWindowStyleMaskTitled | NSWindowStyleMaskResizable) + backing:NSBackingStoreBuffered + defer:NO]; + window.title = @"Gourmand — Inbox"; + NSView *content = window.contentView; + + // Sidebar: a vibrancy material, the classic source-list look. + NSVisualEffectView *sidebar = [[NSVisualEffectView alloc] initWithFrame:NSMakeRect(0, 0, 200, 420)]; + sidebar.material = NSVisualEffectMaterialSidebar; + sidebar.blendingMode = NSVisualEffectBlendingModeBehindWindow; + sidebar.identifier = @"Sidebar"; + [content addSubview:sidebar]; + + // A layer-backed selection row: 6pt corner radius, accent fill. + NSView *selectionRow = [[NSView alloc] initWithFrame:NSMakeRect(8, 350, 184, 28)]; + selectionRow.wantsLayer = YES; + selectionRow.layer.cornerRadius = 6; + if (@available(macOS 10.14, *)) { + selectionRow.layer.backgroundColor = NSColor.controlAccentColor.CGColor; + } else { + selectionRow.layer.backgroundColor = NSColor.systemBlueColor.CGColor; + } + selectionRow.identifier = @"SelectionRow"; + [sidebar addSubview:selectionRow]; + + // A label with a known font. + NSTextField *rowLabel = [NSTextField labelWithString:@"Inbox"]; + rowLabel.font = [NSFont systemFontOfSize:13 weight:NSFontWeightSemibold]; + rowLabel.frame = NSMakeRect(16, 354, 160, 18); + rowLabel.identifier = @"InboxLabel"; + [sidebar addSubview:rowLabel]; + + // Detail pane with a constrained title. + NSView *detail = [[NSView alloc] initWithFrame:NSMakeRect(200, 0, 440, 420)]; + detail.identifier = @"Detail"; + [content addSubview:detail]; + + NSTextField *title = [NSTextField labelWithString:@"Welcome"]; + title.font = [NSFont systemFontOfSize:22 weight:NSFontWeightBold]; + title.translatesAutoresizingMaskIntoConstraints = NO; + title.identifier = @"Title"; + [detail addSubview:title]; + [detail addConstraint:[NSLayoutConstraint constraintWithItem:title + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:detail + attribute:NSLayoutAttributeLeading + multiplier:1 + constant:24]]; + [detail addConstraint:[NSLayoutConstraint constraintWithItem:title + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:detail + attribute:NSLayoutAttributeTop + multiplier:1 + constant:24]]; + + [window orderFront:nil]; + + // Walk + project to JSON via the real walker. + NSArray *windows = [FLEXAppKitWalker snapshotApplicationWindows]; + NSDictionary *out = @{ + @"windows": [FLEXAppKitJSON dictionariesForWindows:windows], + @"constraintsForTitle": [FLEXAppKitJSON dictionaryForConstraintNode: + [FLEXConstraintNode constraintsForView:title]], + }; + + NSError *error = nil; + NSData *json = [NSJSONSerialization dataWithJSONObject:out + options:NSJSONWritingPrettyPrinted | NSJSONWritingSortedKeys + error:&error]; + if (json == nil) { + fprintf(stderr, "JSON error: %s\n", error.localizedDescription.UTF8String); + return 1; + } + fwrite(json.bytes, 1, json.length, stdout); + printf("\n"); + return 0; + } +} From 134c5c8158f923cfb18fbb2810abf1a4097ba810 Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Fri, 5 Jun 2026 23:44:18 -0500 Subject: [PATCH 7/7] chore: prepare the AppKit walker for upstream review 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. --- .../ViewHierarchy/AppKit/FLEXAppKitColor.h | 2 - .../ViewHierarchy/AppKit/FLEXAppKitColor.m | 2 - Classes/ViewHierarchy/AppKit/FLEXAppKitFont.h | 2 - Classes/ViewHierarchy/AppKit/FLEXAppKitFont.m | 4 +- Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.h | 10 +- Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.m | 2 - .../ViewHierarchy/AppKit/FLEXAppKitLayer.h | 2 - .../ViewHierarchy/AppKit/FLEXAppKitLayer.m | 2 - .../AppKit/FLEXAppKitViewSnapshot.h | 2 - .../AppKit/FLEXAppKitViewSnapshot.m | 2 - .../AppKit/FLEXAppKitViewSnapshot_Internal.h | 2 - .../ViewHierarchy/AppKit/FLEXAppKitWalker.h | 7 +- .../ViewHierarchy/AppKit/FLEXAppKitWalker.m | 8 +- .../AppKit/FLEXAppKitWindowSnapshot.h | 2 - .../AppKit/FLEXAppKitWindowSnapshot.m | 2 - .../FLEXAppKitWindowSnapshot_Internal.h | 2 - .../ViewHierarchy/AppKit/FLEXConstraintNode.h | 2 - .../ViewHierarchy/AppKit/FLEXConstraintNode.m | 4 +- Package.swift | 230 +++++++++--------- 19 files changed, 124 insertions(+), 165 deletions(-) diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h index f5099bbb9f..581f55aad0 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h @@ -16,8 +16,6 @@ // a no-op. Read view-level colors as NSColor (not via the layer) to recover the // catalog name and appearance. // -// SPEC: domain.walker -// #import #import diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m index 366d9ba1a5..85cde3394f 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m @@ -2,8 +2,6 @@ // FLEXAppKitColor.m // FLEX // -// SPEC: domain.walker -// #import "FLEXAppKitColor.h" diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.h index 0bf4f31bc2..5eceb4edaa 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.h @@ -6,8 +6,6 @@ // weight trait AND the nearest named weight — never a lossy NSFontManager // (1–14) conversion. // -// SPEC: domain.walker -// #import diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.m index 42c090546c..d9de2870f5 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.m +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.m @@ -2,8 +2,6 @@ // FLEXAppKitFont.m // FLEX // -// SPEC: domain.walker -// #import "FLEXAppKitFont.h" @@ -21,7 +19,7 @@ @interface FLEXAppKitFont () @end /// The font, read off `object` directly or off its `-cell`, or nil. The carrier set is -/// "any object responding to -font" per domain.walker — not a hardcoded class list. +/// "any object responding to -font" — not a hardcoded class list. static NSFont *FLEXFontFromCarrier(id object) { if (object == nil) { return nil; diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.h index 10eeb715bc..84bf9d5f3d 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.h @@ -3,13 +3,9 @@ // FLEX // // Projects the walker's snapshot model into JSON-serializable Foundation -// dictionaries shaped per the flexscope node schema (domain.node). Floats are -// emitted at fixed precision and nils as NSNull so the output is deterministic -// and round-trips through NSJSONSerialization. The final string serialization is -// the consumer's job (ARCHITECTURE §5.3: the FLEX side returns Foundation -// collections; the server/CLI serializes). -// -// SPEC: domain.walker +// dictionaries (floats at fixed precision, nils as NSNull) so the output is +// deterministic and round-trips through NSJSONSerialization. The final string +// serialization is left to the consumer — this returns Foundation collections. // #import diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.m index 226d2dd81b..52aeeba107 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.m +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.m @@ -2,8 +2,6 @@ // FLEXAppKitJSON.m // FLEX // -// SPEC: domain.walker -// #import "FLEXAppKitJSON.h" diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h index 5e134f00c2..f615ff3060 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h @@ -7,8 +7,6 @@ // and standalone sublayers (backing no view) are included here. CALayer is the // same class on macOS and iOS, so this is cross-platform. // -// SPEC: domain.walker -// #import #import diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m index a8cb174030..9820809533 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m @@ -2,8 +2,6 @@ // FLEXAppKitLayer.m // FLEX // -// SPEC: domain.walker -// #import "FLEXAppKitLayer.h" diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h index 5cbb9174bb..1c1d834993 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h @@ -6,8 +6,6 @@ // of FHSViewSnapshot. Captures only the facts read on the main thread; holds no // live NSView, so it is safe to serialize off-main. // -// SPEC: domain.walker -// #import #import diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m index 6c55bf8596..8f91c31b76 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m @@ -2,8 +2,6 @@ // FLEXAppKitViewSnapshot.m // FLEX // -// SPEC: domain.walker -// #import "FLEXAppKitViewSnapshot_Internal.h" diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h index f973cea443..7db304dad7 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h @@ -5,8 +5,6 @@ // Internal readwrite surface so FLEXAppKitWalker can populate an otherwise // immutable snapshot during construction. Not part of the public contract. // -// SPEC: domain.walker -// #import "FLEXAppKitViewSnapshot.h" diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h index 5a9cf662e7..4b750c7d15 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h @@ -5,11 +5,8 @@ // The macOS view-tree walker: NSApp → NSWindow → NSView, capturing the per-node // facts in FLEXAppKitViewSnapshot. The macOS analog of FHSView. // -// Threading: every method must be called on the target's main thread. Main-thread -// marshaling, the socket, and the node-id registry are the headless server's job, -// not the walker's (see domain.walker). -// -// SPEC: domain.walker +// Threading: every method must be called on the main thread — AppKit view state +// is main-thread-only; reading it off-main is undefined behavior. // #import diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m index fb90fdae2b..f821af486a 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m @@ -2,8 +2,6 @@ // FLEXAppKitWalker.m // FLEX // -// SPEC: domain.walker -// #import "FLEXAppKitWalker.h" @@ -247,9 +245,9 @@ + (nullable FLEXAppKitViewSnapshot *)snapshotForHitTestAtPoint:(CGPoint)point return [self snapshotForView:hit inWindow:window maxDepth:0]; } -/// Normalized top-left rect, full-window-frame-relative (titlebar included), per -/// domain.walker. Computed through screen coordinates so per-view isFlipped is -/// resolved by AppKit's own conversion rather than manual y-flipping. +/// Normalized top-left rect, full-window-frame-relative (titlebar included). +/// Computed through screen coordinates so per-view isFlipped is resolved by +/// AppKit's own conversion rather than manual y-flipping. + (CGRect)topLeftFrameForView:(NSView *)view inWindow:(nullable NSWindow *)window { if (window == nil) { return view.frame; diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h index 547d2f0da2..b427d08684 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h @@ -5,8 +5,6 @@ // A top-level NSWindow root produced by FLEXAppKitWalker. Each on-screen window // is a tree root; its contentView subtree hangs below. // -// SPEC: domain.walker -// #import #import diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.m index 1c06645f3b..c10e5085ef 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.m +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.m @@ -2,8 +2,6 @@ // FLEXAppKitWindowSnapshot.m // FLEX // -// SPEC: domain.walker -// #import "FLEXAppKitWindowSnapshot_Internal.h" diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h index 614812b8b9..808820ba34 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h @@ -4,8 +4,6 @@ // // Internal readwrite surface for FLEXAppKitWalker. Not part of the public contract. // -// SPEC: domain.walker -// #import "FLEXAppKitWindowSnapshot.h" diff --git a/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.h b/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.h index 099a26fcbd..5181c5be1b 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.h +++ b/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.h @@ -11,8 +11,6 @@ // Node-id stringification of each item is the server's concern; this captures the // AppKit facts + each item's class and role (target / view / layoutGuide / none). // -// SPEC: domain.walker -// #import #import diff --git a/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m b/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m index ec1ba381a0..ee102e5918 100644 --- a/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m +++ b/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m @@ -2,8 +2,6 @@ // FLEXConstraintNode.m // FLEX // -// SPEC: domain.walker -// #import "FLEXConstraintNode.h" @@ -162,7 +160,7 @@ + (instancetype)constraintsForView:(NSView *)view { // its own constraints that reference it, plus any ancestor-held constraint that // references it (AppKit has no public reverse index). A view also holds // constraints purely between its descendants — those don't touch it and are - // excluded, matching the spec's "constraints that touch it". + // excluded (they do not touch the view). collect(view.constraints, YES); for (NSView *ancestor = view.superview; ancestor != nil; ancestor = ancestor.superview) { collect(ancestor.constraints, YES); diff --git a/Package.swift b/Package.swift index 60115b7895..0ad264397c 100644 --- a/Package.swift +++ b/Package.swift @@ -3,133 +3,131 @@ import PackageDescription enum FLEXBuildOptions { - /// Set this to `true` to use `.unsafeFlags` to silence warnings. - static let silenceWarnings = false + /// Set this to `true` to use `.unsafeFlags` to silence warnings. + static let silenceWarnings = false } #if swift(>=5.9) - let platforms: [PackageDescription.SupportedPlatform] = [.iOS(.v12)] +let platforms: [PackageDescription.SupportedPlatform] = [.iOS(.v12)] #elseif swift(>=5.7) - let platforms: [PackageDescription.SupportedPlatform] = [.iOS(.v11)] +let platforms: [PackageDescription.SupportedPlatform] = [.iOS(.v11)] #else - let platforms: [PackageDescription.SupportedPlatform] = [.iOS(.v10)] +let platforms: [PackageDescription.SupportedPlatform] = [.iOS(.v10)] #endif let package = Package( - name: "FLEX", - platforms: platforms, - products: [ - .library(name: "FLEX", targets: ["FLEX"]), - .library(name: "FLEXAppKit", targets: ["FLEXAppKit"]), - ], - targets: [ - .target( - name: "FLEX", - path: "Classes", - exclude: [ - "Info.plist", - "ViewHierarchy/AppKit", - "Utility/APPLE_LICENSE", - "Network/OSCache/LICENSE.md", - "Network/PonyDebugger/LICENSE", - "GlobalStateExplorers/DatabaseBrowser/LICENSE", - "GlobalStateExplorers/Keychain/SSKeychain_LICENSE", - "GlobalStateExplorers/SystemLog/LLVM_LICENSE.TXT", - ], - publicHeadersPath: "Headers", - cSettings: .headerSearchPaths + .warningFlags, - linkerSettings: [ - .linkedFramework("CoreGraphics"), - .linkedLibrary("sqlite3"), - .linkedLibrary("z"), - ] - ), - .target( - name: "FLEXAppKit", - path: "Classes/ViewHierarchy/AppKit", - publicHeadersPath: ".", - linkerSettings: [ - .linkedFramework("AppKit", .when(platforms: [.macOS])) - ] - ), - .target( - name: "FLEXAppKitProbe", - dependencies: ["FLEXAppKit"], - path: "DevProbe" - ), - .target( - name: "SampleAppKitDump", - dependencies: ["FLEXAppKit"], - path: "Samples/SampleAppKitDump" - ), - ], - // Required to compile FLEXSwiftInternal.mm - cxxLanguageStandard: .gnucxx11 + name: "FLEX", + platforms: platforms, + products: [ + .library(name: "FLEX", targets: ["FLEX"]), + .library(name: "FLEXAppKit", targets: ["FLEXAppKit"]), + ], + targets: [ + .target( + name: "FLEX", + path: "Classes", + exclude: [ + "Info.plist", + "ViewHierarchy/AppKit", + "Utility/APPLE_LICENSE", + "Network/OSCache/LICENSE.md", + "Network/PonyDebugger/LICENSE", + "GlobalStateExplorers/DatabaseBrowser/LICENSE", + "GlobalStateExplorers/Keychain/SSKeychain_LICENSE", + "GlobalStateExplorers/SystemLog/LLVM_LICENSE.TXT", + ], + publicHeadersPath: "Headers", + cSettings: .headerSearchPaths + .warningFlags, + linkerSettings: [ + .linkedFramework("CoreGraphics"), + .linkedLibrary("sqlite3"), + .linkedLibrary("z"), + ] + ), + .target( + name: "FLEXAppKit", + path: "Classes/ViewHierarchy/AppKit", + publicHeadersPath: ".", + linkerSettings: [ + .linkedFramework("AppKit", .when(platforms: [.macOS])) + ] + ), + .target( + name: "FLEXAppKitProbe", + dependencies: ["FLEXAppKit"], + path: "DevProbe" + ), + .target( + name: "SampleAppKitDump", + dependencies: ["FLEXAppKit"], + path: "Samples/SampleAppKitDump" + ), + ], + // Required to compile FLEXSwiftInternal.mm + cxxLanguageStandard: .gnucxx11 ) extension Array where Element == CSetting { - static var warningFlags: [Element] { - if FLEXBuildOptions.silenceWarnings { - return [ - .unsafeFlags([ - "-Wno-deprecated-declarations", - "-Wno-strict-prototypes", - "-Wno-unsupported-availability-guard", - ]) - ] - } + static var warningFlags: [Element] { + if FLEXBuildOptions.silenceWarnings { + return [.unsafeFlags([ + "-Wno-deprecated-declarations", + "-Wno-strict-prototypes", + "-Wno-unsupported-availability-guard", + ])] + } - return [] - } + return [] + } - /// These are the header search paths needed for FLEX to compile, not - /// the headers used by projects linking against FLEX. - /// - /// Do not modify the contents of this property by hand; - /// Instead, run `bash generate-spm-headers.sh | grep headerSearchPath | pbcopy` - /// and paste (and indent) the result below. Do this any time new folders are added. - static var headerSearchPaths: [Element] { - [ - .headerSearchPath("Classes"), - .headerSearchPath("Core"), - .headerSearchPath("Core/Controllers"), - .headerSearchPath("Core/Views"), - .headerSearchPath("Core/Views/Cells"), - .headerSearchPath("Core/Views/Carousel"), - .headerSearchPath("ObjectExplorers"), - .headerSearchPath("ObjectExplorers/Sections"), - .headerSearchPath("ObjectExplorers/Sections/Shortcuts"), - .headerSearchPath("Network"), - .headerSearchPath("Network/PonyDebugger"), - .headerSearchPath("Network/OSCache"), - .headerSearchPath("Toolbar"), - .headerSearchPath("Manager"), - .headerSearchPath("Manager/Private"), - .headerSearchPath("Editing"), - .headerSearchPath("Editing/ArgumentInputViews"), - .headerSearchPath("Headers"), - .headerSearchPath("ExplorerInterface"), - .headerSearchPath("ExplorerInterface/Tabs"), - .headerSearchPath("ExplorerInterface/Bookmarks"), - .headerSearchPath("GlobalStateExplorers"), - .headerSearchPath("GlobalStateExplorers/Globals"), - .headerSearchPath("GlobalStateExplorers/Keychain"), - .headerSearchPath("GlobalStateExplorers/FileBrowser"), - .headerSearchPath("GlobalStateExplorers/SystemLog"), - .headerSearchPath("GlobalStateExplorers/DatabaseBrowser"), - .headerSearchPath("GlobalStateExplorers/RuntimeBrowser"), - .headerSearchPath("GlobalStateExplorers/RuntimeBrowser/DataSources"), - .headerSearchPath("ViewHierarchy"), - .headerSearchPath("ViewHierarchy/SnapshotExplorer"), - .headerSearchPath("ViewHierarchy/SnapshotExplorer/Scene"), - .headerSearchPath("ViewHierarchy/TreeExplorer"), - .headerSearchPath("Utility"), - .headerSearchPath("Utility/Runtime"), - .headerSearchPath("Utility/Runtime/Objc"), - .headerSearchPath("Utility/Runtime/Objc/Reflection"), - .headerSearchPath("Utility/Categories"), - .headerSearchPath("Utility/Categories/Private"), - .headerSearchPath("Utility/Keyboard"), - ] - } + /// These are the header search paths needed for FLEX to compile, not + /// the headers used by projects linking against FLEX. + /// + /// Do not modify the contents of this property by hand; + /// Instead, run `bash generate-spm-headers.sh | grep headerSearchPath | pbcopy` + /// and paste (and indent) the result below. Do this any time new folders are added. + static var headerSearchPaths: [Element] { + [ + .headerSearchPath("Classes"), + .headerSearchPath("Core"), + .headerSearchPath("Core/Controllers"), + .headerSearchPath("Core/Views"), + .headerSearchPath("Core/Views/Cells"), + .headerSearchPath("Core/Views/Carousel"), + .headerSearchPath("ObjectExplorers"), + .headerSearchPath("ObjectExplorers/Sections"), + .headerSearchPath("ObjectExplorers/Sections/Shortcuts"), + .headerSearchPath("Network"), + .headerSearchPath("Network/PonyDebugger"), + .headerSearchPath("Network/OSCache"), + .headerSearchPath("Toolbar"), + .headerSearchPath("Manager"), + .headerSearchPath("Manager/Private"), + .headerSearchPath("Editing"), + .headerSearchPath("Editing/ArgumentInputViews"), + .headerSearchPath("Headers"), + .headerSearchPath("ExplorerInterface"), + .headerSearchPath("ExplorerInterface/Tabs"), + .headerSearchPath("ExplorerInterface/Bookmarks"), + .headerSearchPath("GlobalStateExplorers"), + .headerSearchPath("GlobalStateExplorers/Globals"), + .headerSearchPath("GlobalStateExplorers/Keychain"), + .headerSearchPath("GlobalStateExplorers/FileBrowser"), + .headerSearchPath("GlobalStateExplorers/SystemLog"), + .headerSearchPath("GlobalStateExplorers/DatabaseBrowser"), + .headerSearchPath("GlobalStateExplorers/RuntimeBrowser"), + .headerSearchPath("GlobalStateExplorers/RuntimeBrowser/DataSources"), + .headerSearchPath("ViewHierarchy"), + .headerSearchPath("ViewHierarchy/SnapshotExplorer"), + .headerSearchPath("ViewHierarchy/SnapshotExplorer/Scene"), + .headerSearchPath("ViewHierarchy/TreeExplorer"), + .headerSearchPath("Utility"), + .headerSearchPath("Utility/Runtime"), + .headerSearchPath("Utility/Runtime/Objc"), + .headerSearchPath("Utility/Runtime/Objc/Reflection"), + .headerSearchPath("Utility/Categories"), + .headerSearchPath("Utility/Categories/Private"), + .headerSearchPath("Utility/Keyboard") + ] + } }