@reactiive/ennio 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/EnnioCore.podspec +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +50 -0
  4. package/android/CMakeLists.txt +40 -0
  5. package/android/build.gradle +64 -0
  6. package/cpp/ElementMatcher.cpp +661 -0
  7. package/cpp/ElementMatcher.hpp +244 -0
  8. package/cpp/EnnioLog.hpp +182 -0
  9. package/cpp/HybridEnnio.cpp +1161 -0
  10. package/cpp/HybridEnnio.hpp +174 -0
  11. package/cpp/IdleMonitor.hpp +277 -0
  12. package/cpp/Protocol.cpp +135 -0
  13. package/cpp/Protocol.hpp +47 -0
  14. package/cpp/SelectorCriteria.hpp +281 -0
  15. package/cpp/SelectorParser.cpp +649 -0
  16. package/cpp/SelectorParser.hpp +94 -0
  17. package/cpp/ShadowTreeTraverser.cpp +305 -0
  18. package/cpp/ShadowTreeTraverser.hpp +142 -0
  19. package/cpp/TestIDRegistry.cpp +109 -0
  20. package/cpp/TestIDRegistry.hpp +84 -0
  21. package/dist/cli.js +16221 -0
  22. package/ios/EnnioAutoInit.mm +338 -0
  23. package/ios/EnnioDebugBanner.h +19 -0
  24. package/ios/EnnioDebugBanner.mm +178 -0
  25. package/ios/EnnioRuntimeHelper.h +264 -0
  26. package/ios/EnnioRuntimeHelper.mm +2443 -0
  27. package/lib/Ennio.nitro.d.ts +263 -0
  28. package/lib/Ennio.nitro.d.ts.map +1 -0
  29. package/lib/Ennio.nitro.js +2 -0
  30. package/lib/Ennio.nitro.js.map +1 -0
  31. package/lib/index.d.ts +16 -0
  32. package/lib/index.d.ts.map +1 -0
  33. package/lib/index.js +45 -0
  34. package/lib/index.js.map +1 -0
  35. package/nitro.json +24 -0
  36. package/nitrogen/generated/.gitattributes +1 -0
  37. package/nitrogen/generated/android/EnnioCore+autolinking.cmake +81 -0
  38. package/nitrogen/generated/android/EnnioCore+autolinking.gradle +27 -0
  39. package/nitrogen/generated/android/EnnioCoreOnLoad.cpp +49 -0
  40. package/nitrogen/generated/android/EnnioCoreOnLoad.hpp +34 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/ennio/EnnioCoreOnLoad.kt +35 -0
  42. package/nitrogen/generated/ios/EnnioCore+autolinking.rb +62 -0
  43. package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Bridge.cpp +17 -0
  44. package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Bridge.hpp +27 -0
  45. package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Umbrella.hpp +38 -0
  46. package/nitrogen/generated/ios/EnnioCoreAutolinking.mm +35 -0
  47. package/nitrogen/generated/ios/EnnioCoreAutolinking.swift +16 -0
  48. package/nitrogen/generated/shared/c++/ExtendedElementInfo.hpp +118 -0
  49. package/nitrogen/generated/shared/c++/HybridEnnioSpec.cpp +44 -0
  50. package/nitrogen/generated/shared/c++/HybridEnnioSpec.hpp +93 -0
  51. package/nitrogen/generated/shared/c++/LayoutMetrics.hpp +103 -0
  52. package/nitrogen/generated/shared/c++/ScrollDirection.hpp +84 -0
  53. package/package.json +78 -0
  54. package/react-native.config.js +14 -0
  55. package/src/Ennio.nitro.ts +363 -0
  56. package/src/cli/hid-daemon.py +129 -0
  57. package/src/index.ts +72 -0
@@ -0,0 +1,338 @@
1
+ //
2
+ // EnnioAutoInit.mm
3
+ // Automatic initialization of Ennio by hooking into React Native setup
4
+ //
5
+
6
+ #import <Foundation/Foundation.h>
7
+ #import <objc/runtime.h>
8
+ #import <objc/message.h>
9
+ #import "EnnioRuntimeHelper.h"
10
+ #import "EnnioDebugBanner.h"
11
+
12
+ #if __has_include(<React/RCTSurfacePresenter.h>)
13
+ #import <React/RCTSurfacePresenter.h>
14
+ #endif
15
+
16
+ #include <jsi/jsi.h>
17
+ #include <functional>
18
+ #include "../cpp/HybridEnnio.hpp"
19
+
20
+ #if __has_include(<ReactCommon/RCTInstance.h>)
21
+ #import <ReactCommon/RCTInstance.h>
22
+ #define ENNIO_HAVE_RCTINSTANCE 1
23
+ #endif
24
+
25
+ // Flag to track if Ennio has been initialized
26
+ static BOOL _ennioInitialized = NO;
27
+
28
+ // Distribution channel detection. Ennio is a dev / QA tool and must
29
+ // refuse to start on App Store production or Enterprise builds. The
30
+ // pod is already excluded from Release configurations at the
31
+ // CocoaPods level, so this is a runtime backstop in case a custom
32
+ // build setup links Ennio into a production binary anyway.
33
+ typedef NS_ENUM(NSInteger, EnnioDistribution) {
34
+ EnnioDistDev,
35
+ EnnioDistAdHoc,
36
+ EnnioDistTestFlight,
37
+ EnnioDistAppStore,
38
+ EnnioDistEnterprise,
39
+ };
40
+
41
+ static EnnioDistribution ennioDetectDistribution(void) {
42
+ #if TARGET_OS_SIMULATOR
43
+ // Simulator builds ship a placeholder StoreKit receipt named
44
+ // "receipt" (same name App Store production builds use), which the
45
+ // logic below would mis-classify as App Store and refuse to start.
46
+ // The Simulator runs no production binary by definition, so short-
47
+ // circuit to Dev before anything else.
48
+ return EnnioDistDev;
49
+ #else
50
+ NSURL* receiptURL = [NSBundle mainBundle].appStoreReceiptURL;
51
+ NSString* receiptName = receiptURL.lastPathComponent;
52
+
53
+ NSString* profilePath = [[NSBundle mainBundle] pathForResource:@"embedded" ofType:@"mobileprovision"];
54
+ NSData* profileData = profilePath ? [NSData dataWithContentsOfFile:profilePath] : nil;
55
+ // .mobileprovision is a CMS-signed blob; the embedded plist XML is
56
+ // ASCII inside binary noise. Latin-1 makes containsString: work
57
+ // without decode failures on the surrounding bytes.
58
+ NSString* profileStr = profileData
59
+ ? [[NSString alloc] initWithData:profileData encoding:NSISOLatin1StringEncoding]
60
+ : nil;
61
+
62
+ BOOL provisionsAllDevices = [profileStr containsString:@"<key>ProvisionsAllDevices</key>"];
63
+ BOOL hasProvisionedDevices = [profileStr containsString:@"<key>ProvisionedDevices</key>"];
64
+ BOOL hasProfile = (profileStr.length > 0);
65
+
66
+ // Enterprise: profile claims to provision all devices.
67
+ if (provisionsAllDevices) return EnnioDistEnterprise;
68
+
69
+ // Receipt-based channel detection (the device chose this at install
70
+ // time, not the build).
71
+ if ([receiptName isEqualToString:@"receipt"]) return EnnioDistAppStore;
72
+ if ([receiptName isEqualToString:@"sandboxReceipt"]) return EnnioDistTestFlight;
73
+
74
+ // Ad-Hoc: profile names a specific device list.
75
+ if (hasProvisionedDevices) return EnnioDistAdHoc;
76
+
77
+ // App Store distribution profile: no ProvisionedDevices, no
78
+ // ProvisionsAllDevices, and no receipt yet. This catches the
79
+ // sideloaded-production-IPA edge case where the build is signed
80
+ // with the App Store profile but Xcode/Configurator pushed it
81
+ // straight to a device without going through TestFlight or App
82
+ // Store, so no receipt was fetched.
83
+ if (hasProfile && !hasProvisionedDevices && !provisionsAllDevices) return EnnioDistAppStore;
84
+
85
+ // Xcode-attached debug builds on real hardware with no embedded
86
+ // profile at all. Treat as Dev.
87
+ return EnnioDistDev;
88
+ #endif
89
+ }
90
+
91
+ static NSString* ennioDistributionName(EnnioDistribution d) {
92
+ switch (d) {
93
+ case EnnioDistDev: return @"Development";
94
+ case EnnioDistAdHoc: return @"Ad-Hoc";
95
+ case EnnioDistTestFlight: return @"TestFlight";
96
+ case EnnioDistAppStore: return @"App Store";
97
+ case EnnioDistEnterprise: return @"Enterprise";
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Hook into RCTHost's start method to capture the surface presenter
103
+ */
104
+ @interface EnnioAutoInit : NSObject
105
+ @end
106
+
107
+ @implementation EnnioAutoInit
108
+
109
+ + (void)load {
110
+ NSLog(@"[Ennio] EnnioAutoInit +load called");
111
+ // Diagnostic marker: when stdout/NSLog gets swallowed (idevicesyslog
112
+ // sometimes drops app debug lines on device), the file proves +load ran.
113
+ NSString* mark = [NSString stringWithFormat:@"%@/Library/_ennio_load_fired.txt", NSHomeDirectory()];
114
+ [@"loaded" writeToFile:mark atomically:YES encoding:NSUTF8StringEncoding error:nil];
115
+
116
+ // Try to swizzle RCTHost's start method
117
+ Class hostClass = NSClassFromString(@"RCTHost");
118
+ if (!hostClass) {
119
+ NSLog(@"[Ennio] RCTHost class not found, trying alternative...");
120
+
121
+ // Try RCTFabricSurface's start method as alternative
122
+ Class surfaceClass = NSClassFromString(@"RCTFabricSurface");
123
+ if (surfaceClass) {
124
+ [self swizzleFabricSurfaceStart:surfaceClass];
125
+ } else {
126
+ NSLog(@"[Ennio] No suitable class found for auto-init");
127
+ }
128
+ return;
129
+ }
130
+
131
+ NSLog(@"[Ennio] Found RCTHost class, setting up swizzling...");
132
+
133
+ SEL originalSelector = @selector(start);
134
+ SEL swizzledSelector = @selector(ennio_start);
135
+
136
+ Method originalMethod = class_getInstanceMethod(hostClass, originalSelector);
137
+ Method swizzledMethod = class_getInstanceMethod(self, swizzledSelector);
138
+
139
+ if (!originalMethod) {
140
+ NSLog(@"[Ennio] Could not find RCTHost start method");
141
+ return;
142
+ }
143
+
144
+ // Add swizzled method to RCTHost class
145
+ BOOL didAddMethod = class_addMethod(
146
+ hostClass,
147
+ swizzledSelector,
148
+ method_getImplementation(swizzledMethod),
149
+ method_getTypeEncoding(swizzledMethod)
150
+ );
151
+
152
+ if (!didAddMethod) {
153
+ NSLog(@"[Ennio] Could not add swizzled method to RCTHost");
154
+ return;
155
+ }
156
+
157
+ // Exchange implementations
158
+ Method newSwizzledMethod = class_getInstanceMethod(hostClass, swizzledSelector);
159
+ method_exchangeImplementations(originalMethod, newSwizzledMethod);
160
+ NSLog(@"[Ennio] RCTHost.start swizzle installed successfully");
161
+ }
162
+
163
+ + (void)swizzleFabricSurfaceStart:(Class)surfaceClass {
164
+ SEL originalSelector = @selector(start);
165
+ SEL swizzledSelector = @selector(ennio_surfaceStart);
166
+
167
+ Method originalMethod = class_getInstanceMethod(surfaceClass, originalSelector);
168
+ Method swizzledMethod = class_getInstanceMethod(self, swizzledSelector);
169
+
170
+ if (!originalMethod) {
171
+ NSLog(@"[Ennio] Could not find RCTFabricSurface start method");
172
+ return;
173
+ }
174
+
175
+ BOOL didAddMethod = class_addMethod(
176
+ surfaceClass,
177
+ swizzledSelector,
178
+ method_getImplementation(swizzledMethod),
179
+ method_getTypeEncoding(swizzledMethod)
180
+ );
181
+
182
+ if (didAddMethod) {
183
+ Method newSwizzledMethod = class_getInstanceMethod(surfaceClass, swizzledSelector);
184
+ method_exchangeImplementations(originalMethod, newSwizzledMethod);
185
+ NSLog(@"[Ennio] RCTFabricSurface.start swizzle installed successfully");
186
+ }
187
+ }
188
+
189
+ // Swizzled RCTHost start method
190
+ - (void)ennio_start {
191
+ NSLog(@"[Ennio] RCTHost.start called");
192
+
193
+ // Call original implementation
194
+ [self ennio_start];
195
+
196
+ // Distribution gate. Even if the CocoaPods `:configurations`
197
+ // gate slipped and Ennio is linked into an App Store / Enterprise
198
+ // binary, refuse to install `__ennioDispatch`, the commit hook,
199
+ // or the ribbon. Runtime backstop on top of the build-time gate.
200
+ EnnioDistribution dist = ennioDetectDistribution();
201
+ if (dist == EnnioDistAppStore || dist == EnnioDistEnterprise) {
202
+ NSLog(@"[Ennio] REFUSING to start: %@ distribution detected. "
203
+ @"Ennio must never run in App Store or Enterprise builds. "
204
+ @"Your build pipeline is leaking a remote-control surface — fix it.",
205
+ ennioDistributionName(dist));
206
+ return;
207
+ }
208
+ NSLog(@"[Ennio] Distribution: %@ — starting", ennioDistributionName(dist));
209
+
210
+ // Pure-native bootstrap: pull RCTInstance from RCTHost's `_instance`
211
+ // ivar, ask it to run a block on the JS thread once the runtime is
212
+ // ready. Inside that block we have a `jsi::Runtime&` — capture it,
213
+ // install the commit-signal walker + the `__ennioDispatch` JSI host
214
+ // function. After this, the user's app never has to import `ennio`;
215
+ // the package autolinks via Pod and bootstraps entirely from native,
216
+ // and the CLI drives it over Hermes Inspector CDP.
217
+ Ivar instanceIvar = class_getInstanceVariable([self class], "_instance");
218
+ if (instanceIvar) {
219
+ id rctInstance = object_getIvar(self, instanceIvar);
220
+ #ifdef ENNIO_HAVE_RCTINSTANCE
221
+ if ([rctInstance isKindOfClass:[RCTInstance class]]) {
222
+ __strong RCTInstance* strongInstance = (RCTInstance*)rctInstance;
223
+ // Background dispatch workers need to schedule the
224
+ // response-write back onto the JS thread (jsi::Runtime is
225
+ // not thread-safe). RCTInstance.callFunctionOnBufferedRuntimeExecutor
226
+ // is the only sanctioned scheduler that hands us a
227
+ // Runtime& on the JS thread; wrap it as a std::function
228
+ // and stash it via HybridEnnio for the worker thread to
229
+ // pick up.
230
+ margelo::nitro::ennio::HybridEnnio::JSThreadExecutor exec =
231
+ [strongInstance](std::function<void(facebook::jsi::Runtime&)>&& fn) {
232
+ [strongInstance callFunctionOnBufferedRuntimeExecutor:std::move(fn)];
233
+ };
234
+ margelo::nitro::ennio::HybridEnnio::setJSThreadExecutor(std::move(exec));
235
+
236
+ // Bootstrap (capture runtime, install commit signal +
237
+ // `__ennioDispatch` JSI host function) once the JS thread
238
+ // is ready. RCTInstance delivers our C++ lambda onto the
239
+ // JS thread.
240
+ std::function<void(facebook::jsi::Runtime&)> boot =
241
+ [](facebook::jsi::Runtime& rt) {
242
+ NSString* m = [NSString stringWithFormat:@"%@/Library/_ennio_jsthread_fired.txt", NSHomeDirectory()];
243
+ [@"jsthread" writeToFile:m atomically:YES encoding:NSUTF8StringEncoding error:nil];
244
+ margelo::nitro::ennio::HybridEnnio::nativeBootstrap(rt);
245
+ NSString* m2 = [NSString stringWithFormat:@"%@/Library/_ennio_bootstrap_returned.txt", NSHomeDirectory()];
246
+ [@"returned" writeToFile:m2 atomically:YES encoding:NSUTF8StringEncoding error:nil];
247
+ };
248
+ [strongInstance callFunctionOnBufferedRuntimeExecutor:std::move(boot)];
249
+ NSLog(@"[Ennio] Scheduled nativeBootstrap on JS thread");
250
+ // Diagnostic marker: confirms ennio_start ran AND nativeBootstrap was scheduled.
251
+ NSString* mark2 = [NSString stringWithFormat:@"%@/Library/_ennio_bootstrap_scheduled.txt", NSHomeDirectory()];
252
+ [[NSString stringWithFormat:@"dist=%@", ennioDistributionName(dist)]
253
+ writeToFile:mark2 atomically:YES encoding:NSUTF8StringEncoding error:nil];
254
+
255
+ // Loud, greppable announce. If this line ever shows up in
256
+ // Console.app on a non-dev device, the build pipeline has
257
+ // leaked Ennio into a build it shouldn't be in.
258
+ NSLog(@"[Ennio] __ennioDispatch host function installed (distribution: %@) — "
259
+ @"if you see this in production, your build pipeline is broken.",
260
+ ennioDistributionName(dist));
261
+
262
+ // Show the top-right "E2E" ribbon — opt-in via the
263
+ // `ENNIORibbonEnabled` Info.plist key (written by
264
+ // `ennio-expo-plugin` when `showRibbon: true`). Default
265
+ // off so devs iterating on UI don't get an always-on
266
+ // overlay. Release builds don't compile this file at all
267
+ // (CocoaPods :configurations gate), so the check is only
268
+ // ever reached in Debug.
269
+ id ribbonFlag = [[NSBundle mainBundle].infoDictionary objectForKey:@"ENNIORibbonEnabled"];
270
+ BOOL showRibbon = [ribbonFlag isKindOfClass:[NSNumber class]] && [(NSNumber*)ribbonFlag boolValue];
271
+ if (showRibbon) {
272
+ [EnnioDebugBanner show];
273
+ }
274
+ } else {
275
+ NSLog(@"[Ennio] _instance is not an RCTInstance (got %@)", [rctInstance class]);
276
+ }
277
+ #else
278
+ NSLog(@"[Ennio] RCTInstance.h not available — falling back to JS-side bootstrap");
279
+ (void)rctInstance;
280
+ #endif
281
+ } else {
282
+ NSLog(@"[Ennio] Could not find RCTHost._instance ivar");
283
+ }
284
+
285
+ // Get surface presenter from RCTHost
286
+ if (!_ennioInitialized) {
287
+ // Use performSelector to avoid compile-time dependency
288
+ SEL surfacePresenterSel = @selector(surfacePresenter);
289
+ if ([self respondsToSelector:surfacePresenterSel]) {
290
+ #pragma clang diagnostic push
291
+ #pragma clang diagnostic ignored "-Warc-performSelector-leaks"
292
+ id surfacePresenter = [self performSelector:surfacePresenterSel];
293
+ #pragma clang diagnostic pop
294
+
295
+ if (surfacePresenter) {
296
+ EnnioSetSurfacePresenter((__bridge RCTSurfacePresenter *)(__bridge void *)surfacePresenter);
297
+ _ennioInitialized = YES;
298
+ NSLog(@"[Ennio] Surface presenter captured from RCTHost");
299
+ } else {
300
+ NSLog(@"[Ennio] RCTHost.surfacePresenter returned nil");
301
+ }
302
+ } else {
303
+ NSLog(@"[Ennio] RCTHost does not respond to surfacePresenter");
304
+ }
305
+ }
306
+ }
307
+
308
+ // Swizzled RCTFabricSurface start method (alternative path)
309
+ - (void)ennio_surfaceStart {
310
+ NSLog(@"[Ennio] RCTFabricSurface.start called");
311
+
312
+ // Call original implementation
313
+ [self ennio_surfaceStart];
314
+
315
+ // Try to get surface presenter from the surface
316
+ if (!_ennioInitialized) {
317
+ // The surface should have access to its presenter
318
+ // This is a fallback - not all surfaces expose this
319
+ NSLog(@"[Ennio] Attempting to find surface presenter from RCTFabricSurface...");
320
+
321
+ // Check if there's a presenter property
322
+ SEL presenterSel = @selector(surfacePresenter);
323
+ if ([self respondsToSelector:presenterSel]) {
324
+ #pragma clang diagnostic push
325
+ #pragma clang diagnostic ignored "-Warc-performSelector-leaks"
326
+ id presenter = [self performSelector:presenterSel];
327
+ #pragma clang diagnostic pop
328
+
329
+ if (presenter) {
330
+ EnnioSetSurfacePresenter((__bridge RCTSurfacePresenter *)(__bridge void *)presenter);
331
+ _ennioInitialized = YES;
332
+ NSLog(@"[Ennio] Surface presenter captured from RCTFabricSurface");
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ @end
@@ -0,0 +1,19 @@
1
+ //
2
+ // EnnioDebugBanner.h
3
+ // Flutter-style top-right diagonal "E2E" ribbon, shown whenever Ennio
4
+ // is active in the running build. Touches pass through — the ribbon
5
+ // never intercepts user interaction or test-runner taps.
6
+ //
7
+
8
+ #import <Foundation/Foundation.h>
9
+
10
+ NS_ASSUME_NONNULL_BEGIN
11
+
12
+ @interface EnnioDebugBanner : NSObject
13
+
14
+ /// Idempotent. Safe to call from any thread; attach happens on main.
15
+ + (void)show;
16
+
17
+ @end
18
+
19
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,178 @@
1
+ //
2
+ // EnnioDebugBanner.mm
3
+ //
4
+
5
+ #import "EnnioDebugBanner.h"
6
+ #import <UIKit/UIKit.h>
7
+
8
+ // ─────────────────────────────────────────────────────────────────────
9
+ // Pass-through window: hit-test always nil, so taps never land on the
10
+ // banner — they fall through to whatever app view is below. Same trick
11
+ // Flutter uses for its DEBUG ribbon.
12
+ // ─────────────────────────────────────────────────────────────────────
13
+
14
+ @interface EnnioPassthroughWindow : UIWindow
15
+ @end
16
+
17
+ @implementation EnnioPassthroughWindow
18
+ - (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event { return nil; }
19
+ @end
20
+
21
+ // ─────────────────────────────────────────────────────────────────────
22
+ // Ribbon view: 150x150 container anchored top-right. A red UIView
23
+ // "stripe" subview sits diagonally across the corner via a +45°
24
+ // rotation transform — same orientation as Flutter's debug banner.
25
+ // A bold white UILabel inside the stripe reads naturally along the
26
+ // diagonal. No custom drawRect → CALayer handles all the geometry,
27
+ // shadow, and text rasterisation cleanly.
28
+ // ─────────────────────────────────────────────────────────────────────
29
+
30
+ static CGFloat const kEnnioBannerSize = 130.0;
31
+ static CGFloat const kEnnioStripeWidth = 160.0; // longer than diagonal
32
+ static CGFloat const kEnnioStripeHeight = 18.0;
33
+ static CGFloat const kEnnioStripeFromCorner = 42.0; // px from corner (along diagonal)
34
+
35
+ @interface EnnioRibbonView : UIView
36
+ @end
37
+
38
+ @implementation EnnioRibbonView
39
+
40
+ - (instancetype)init {
41
+ self = [super initWithFrame:CGRectMake(0, 0, kEnnioBannerSize, kEnnioBannerSize)];
42
+ if (self) {
43
+ self.backgroundColor = [UIColor clearColor];
44
+ self.userInteractionEnabled = NO;
45
+ self.clipsToBounds = NO;
46
+
47
+ // Stripe — solid red, with a soft shadow so it stays visible
48
+ // on any background.
49
+ UIView* stripe = [[UIView alloc] initWithFrame:
50
+ CGRectMake(0, 0, kEnnioStripeWidth, kEnnioStripeHeight)];
51
+ stripe.backgroundColor = [UIColor systemRedColor];
52
+ stripe.layer.shadowColor = [UIColor blackColor].CGColor;
53
+ stripe.layer.shadowOpacity = 0.30;
54
+ stripe.layer.shadowOffset = CGSizeMake(0, 1);
55
+ stripe.layer.shadowRadius = 2.5;
56
+ stripe.userInteractionEnabled = NO;
57
+
58
+ // Label — bold white "E2E", filling the stripe bounds, drawn
59
+ // in unrotated coordinates so the rotation transform applies
60
+ // to label + background as one unit.
61
+ UILabel* label = [[UILabel alloc] initWithFrame:stripe.bounds];
62
+ label.text = @"E2E";
63
+ label.font = [UIFont systemFontOfSize:10 weight:UIFontWeightHeavy];
64
+ label.textColor = [UIColor whiteColor];
65
+ label.textAlignment = NSTextAlignmentCenter;
66
+ label.adjustsFontSizeToFitWidth = NO;
67
+ label.userInteractionEnabled = NO;
68
+ // 1.5pt letter-spacing for slightly more breathing room.
69
+ NSMutableAttributedString* attr =
70
+ [[NSMutableAttributedString alloc] initWithString:label.text];
71
+ [attr addAttribute:NSKernAttributeName value:@1.0
72
+ range:NSMakeRange(0, label.text.length)];
73
+ [attr addAttribute:NSFontAttributeName value:label.font
74
+ range:NSMakeRange(0, label.text.length)];
75
+ [attr addAttribute:NSForegroundColorAttributeName value:label.textColor
76
+ range:NSMakeRange(0, label.text.length)];
77
+ label.attributedText = attr;
78
+ [stripe addSubview:label];
79
+
80
+ [self addSubview:stripe];
81
+
82
+ // Position stripe so its centre sits on the diagonal that runs
83
+ // from top-right corner toward the lower-left of the view.
84
+ // Distance `kEnnioStripeFromCorner` along that diagonal from
85
+ // the corner: (corner.x - d/√2, corner.y + d/√2).
86
+ const CGFloat half = kEnnioStripeFromCorner * M_SQRT1_2;
87
+ stripe.center = CGPointMake(kEnnioBannerSize - half, half);
88
+
89
+ // +45° rotation = clockwise from the stripe's natural
90
+ // horizontal orientation. With the stripe's centre on the
91
+ // top-right diagonal, the rotated stripe lies along that
92
+ // diagonal and "E2E" reads naturally with a 45° head tilt to
93
+ // the right — matches Flutter's topEnd debug banner.
94
+ stripe.transform = CGAffineTransformMakeRotation(M_PI_4);
95
+ }
96
+ return self;
97
+ }
98
+
99
+ @end
100
+
101
+ // ─────────────────────────────────────────────────────────────────────
102
+ // Public banner singleton.
103
+ // ─────────────────────────────────────────────────────────────────────
104
+
105
+ @interface EnnioDebugBanner ()
106
+ @property (nonatomic, strong, nullable) UIWindow* window;
107
+ @end
108
+
109
+ @implementation EnnioDebugBanner
110
+
111
+ + (instancetype)shared {
112
+ static EnnioDebugBanner* instance;
113
+ static dispatch_once_t once;
114
+ dispatch_once(&once, ^{
115
+ instance = [[EnnioDebugBanner alloc] init];
116
+ });
117
+ return instance;
118
+ }
119
+
120
+ + (void)show {
121
+ dispatch_async(dispatch_get_main_queue(), ^{
122
+ [[self shared] attach];
123
+ });
124
+ }
125
+
126
+ - (void)attach {
127
+ if (self.window) return;
128
+
129
+ UIWindowScene* scene = [self foregroundScene];
130
+ if (!scene) {
131
+ // Scene not ready yet — retry shortly. RCTHost.start fires
132
+ // before the first scene activates on cold launch.
133
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 250 * NSEC_PER_MSEC),
134
+ dispatch_get_main_queue(),
135
+ ^{ [self attach]; });
136
+ return;
137
+ }
138
+
139
+ UIWindow* w = [[EnnioPassthroughWindow alloc] initWithWindowScene:scene];
140
+ w.windowLevel = UIWindowLevelStatusBar + 1;
141
+ w.backgroundColor = [UIColor clearColor];
142
+ w.userInteractionEnabled = NO;
143
+
144
+ UIViewController* vc = [[UIViewController alloc] init];
145
+ vc.view.backgroundColor = [UIColor clearColor];
146
+ vc.view.userInteractionEnabled = NO;
147
+
148
+ EnnioRibbonView* ribbon = [[EnnioRibbonView alloc] init];
149
+ ribbon.translatesAutoresizingMaskIntoConstraints = NO;
150
+ [vc.view addSubview:ribbon];
151
+
152
+ // Pin to top-right corner of the window — outside the safe area on
153
+ // purpose, matching Flutter's DEBUG banner that runs into the
154
+ // status bar / notch area.
155
+ [NSLayoutConstraint activateConstraints:@[
156
+ [ribbon.topAnchor constraintEqualToAnchor:vc.view.topAnchor],
157
+ [ribbon.trailingAnchor constraintEqualToAnchor:vc.view.trailingAnchor],
158
+ [ribbon.widthAnchor constraintEqualToConstant:kEnnioBannerSize],
159
+ [ribbon.heightAnchor constraintEqualToConstant:kEnnioBannerSize],
160
+ ]];
161
+
162
+ w.rootViewController = vc;
163
+ w.hidden = NO;
164
+ self.window = w;
165
+ }
166
+
167
+ - (UIWindowScene*)foregroundScene {
168
+ for (UIScene* s in [UIApplication sharedApplication].connectedScenes) {
169
+ if (![s isKindOfClass:[UIWindowScene class]]) continue;
170
+ if (s.activationState == UISceneActivationStateForegroundActive ||
171
+ s.activationState == UISceneActivationStateForegroundInactive) {
172
+ return (UIWindowScene*)s;
173
+ }
174
+ }
175
+ return nil;
176
+ }
177
+
178
+ @end