From cae74e9e0cac13e9fc51fe3349fe3af5578e1969 Mon Sep 17 00:00:00 2001 From: ekisu Date: Sun, 18 Oct 2026 21:06:22 -0300 Subject: [PATCH 1/8] Fix X11 decorations for client-drawn Cocoa windows Synchronize Motif decoration hints on style and titlebar-property changes, including native-window recreation and restoring ordinary WM decorations. Add a ten-case standalone Cocoa/X11 regression probe and document validation on KWin/Xwayland. --- docs/TESTING.md | 27 ++++++++ patches/cocotron.patch | 89 +++++++++++++++++---------- tools/build-riot-x11-runtime.py | 12 +++- tools/riot-window-decorations-check.m | 86 ++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 35 deletions(-) create mode 100644 tools/riot-window-decorations-check.m diff --git a/docs/TESTING.md b/docs/TESTING.md index b1d7df4..11a684d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -119,6 +119,33 @@ Expected: `CORETEXT_FONT_LIST pass=1` with nonzero family/face counts (306/1124 The font probe reproduces Chromium's installed-family enumeration, mandatory family matching and localized-name lookup with a null language output. It checks ownership of the single-match result and that a nonexistent mandatory family produces no substitute. The resize probe checks backing-layer bounds immediately after frame/bounds changes, including a scaled view and a child layer, without relying on input or coordinate conversion to refresh them. It also verifies that native parent size is current during child layout, and that adding an overlay sibling after the content view does not redirect subsequent content resizing into that overlay. The old sibling selection left content at 1600×900 while stretching the 30×20 overlay to 1280×720; the corrected layout resizes the content and preserves the overlay. Before the fixes, these checks report a stale 1280×720 layer and native parent after a 1600×900 resize. The `FRAME_SIZE_HOOK` check uses a subclass whose `setFrameSize:` publishes its viewport size. It checks growth and shrinkage through parent autoresizing, direct `setFrame:`, and direct `setFrameSize:`. Before component-setter dispatch was corrected only the final direct size setter reached the subclass (one callback); afterward all four updates do, with current geometry visible inside the callback. No probe launches or contacts Riot/League. The user confirmed that the language dropdown and League window resizing work after the final X11 correction (2026-10-15). The practice-tool crash remains unresolved. +### X11 window decorations + +`python3 tools/build-riot-x11-runtime.py` also builds +`build/riot-window-decorations-check`. Install and activate both the patched +AppKit and X11 backend before running it, with clients and the game closed: + +```sh +darling shell env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries \ + DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks \ + /Volumes/SystemRoot/path/to/darling-lol/build/riot-window-decorations-check +``` + +Expected: `WINDOW_DECORATIONS_CHECK cases=10 pass=1`. The independent Cocoa +probe reads `_MOTIF_WM_HINTS` on its own unmapped native windows. It covers a +normal titled window, hidden title alone, hidden plus transparent titlebar, +both reverse transitions, untitled masks with additional flags, switching to +borderless after creation, native-window recreation, and properties set before +the native window exists. It sends no input, maps no windows and does not +launch or contact Riot/League. Check the success marker, not only the launcher +exit status: some Darling versions do not propagate a guest assertion failure. + +Before the fix the hidden/transparent case fails. With the corrected overlays, +the ten cases passed on KDE Wayland/Xwayland. Passive inspection of the reopened +Riot Client also confirmed the no-decoration hint and identical compositor frame +and client dimensions (1536×864), instead of the previous 28-pixel titlebar. +This validates the Riot Client window, not every LeagueClientUx or game mode. + ### Presentation publisher handoff With clients closed, run `build/riot-client-resize-check --handoff` through Darling with the normal runtime overrides. This creates an owned window and two fixture frame slots. The live software owner first supplies a 1280×720 main frame, then switches to a 262×131 popup; another slot supplies the resized main frame without the software flag. The test samples its own window beyond the original frame extent and checks generation colors across growth and shrinkage. The old selector reports black at the 1600×900 sample; the correction passes all four sizes. Repeat with `RCLOL_XSHM=0` and with `DARLING_XSHM_LIBRARY` pointing to the normal transport to cover both paths. The fixtures remove their own frame slots on exit; they do not read client pixels or send input. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 72d30f8..186b72f 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -1946,7 +1946,7 @@ index d545120a..79760f47 100644 - (BOOL) inLiveResize { return _isInLiveResize; -@@ -1561,6 +1766,117 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1561,6 +1766,123 @@ static BOOL _allowsAutomaticWindowTabbing; return point; } @@ -1976,7 +1976,10 @@ index d545120a..79760f47 100644 +} + +- (void) setTitlebarAppearsTransparent: (BOOL) value { ++ if (_titlebarAppearsTransparent == value) ++ return; + _titlebarAppearsTransparent = value; ++ [_platformWindow setStyleMask: _styleMask]; +} + +- (NSInteger) titleVisibility { @@ -1984,7 +1987,10 @@ index d545120a..79760f47 100644 +} + +- (void) setTitleVisibility: (NSInteger) value { ++ if (_titleVisibility == value) ++ return; + _titleVisibility = value; ++ [_platformWindow setStyleMask: _styleMask]; +} + +- (BOOL) movableByWindowBackground { @@ -2064,7 +2070,7 @@ index d545120a..79760f47 100644 - (NSRect) frameRectForContentRect: (NSRect) contentRect { // hasMainMenu is an instance method so we can't just use the class method // frameRectForContentRect:styleMask: -@@ -1580,6 +1896,10 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1580,6 +1902,10 @@ static BOOL _allowsAutomaticWindowTabbing; } - (NSRect) contentRectForFrameRect: (NSRect) frameRect { @@ -2075,7 +2081,7 @@ index d545120a..79760f47 100644 NSRect result = [[NSDisplay currentDisplay] insetRect: frameRect forNativeWindowBorderWithStyle: [self styleMask]]; -@@ -2155,6 +2475,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2155,6 +2481,7 @@ static BOOL _allowsAutomaticWindowTabbing; - (void) orderWindow: (NSWindowOrderingMode) place relativeTo: (NSInteger) relativeTo { @@ -2083,7 +2089,7 @@ index d545120a..79760f47 100644 // The move notifications are sent under unknown conditions around // orderFront: in the Apple AppKit, we do them all the time here until it's // figured out. I suspect it is a side effect of off-screen windows being at -@@ -2208,6 +2529,8 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2208,6 +2535,8 @@ static BOOL _allowsAutomaticWindowTabbing; break; } @@ -2092,7 +2098,7 @@ index d545120a..79760f47 100644 [self postNotificationName: NSWindowDidMoveNotification]; } -@@ -2257,6 +2580,12 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2257,6 +2586,12 @@ static BOOL _allowsAutomaticWindowTabbing; } - (void) sendEvent: (NSEvent *) event { @@ -2105,7 +2111,7 @@ index d545120a..79760f47 100644 // Some events can cause our window to be destroyed // So make sure self lives at least through this current run loop... [[self retain] autorelease]; -@@ -2300,6 +2629,10 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2300,6 +2635,10 @@ static BOOL _allowsAutomaticWindowTabbing; case NSLeftMouseDown: { NSView *view = [_backgroundView hitTest: [event locationInWindow]]; @@ -2116,7 +2122,7 @@ index d545120a..79760f47 100644 if ([view acceptsFirstResponder]) { if ([view needsPanelToBecomeKey]) { -@@ -2307,6 +2640,27 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2307,6 +2646,27 @@ static BOOL _allowsAutomaticWindowTabbing; } } @@ -2144,7 +2150,7 @@ index d545120a..79760f47 100644 // Event goes to view, not first responder. [view mouseDown: event]; _mouseDownLocationInWindow = [event locationInWindow]; -@@ -2330,6 +2684,31 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2330,6 +2690,31 @@ static BOOL _allowsAutomaticWindowTabbing; _mouseDownLocationInWindow = NSMakePoint(NAN, NAN); break; @@ -2176,7 +2182,7 @@ index d545120a..79760f47 100644 case NSMouseMoved: { NSView *hit = [_backgroundView hitTest: [event locationInWindow]]; -@@ -2478,6 +2857,197 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2478,6 +2863,197 @@ static BOOL _allowsAutomaticWindowTabbing; _draggedTypes = nil; } @@ -2374,7 +2380,7 @@ index d545120a..79760f47 100644 - (void) dragImage: (NSImage *) image at: (NSPoint) location offset: (NSSize) offset -@@ -2486,6 +3056,10 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2486,6 +3062,10 @@ static BOOL _allowsAutomaticWindowTabbing; source: (id) source slideBack: (BOOL) slideBack { @@ -2385,7 +2391,7 @@ index d545120a..79760f47 100644 [[NSDraggingManager draggingManager] dragImage: image at: location offset: offset -@@ -2536,6 +3110,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2536,6 +3116,7 @@ static BOOL _allowsAutomaticWindowTabbing; } else { [[self platformWindow] hideWindow]; } @@ -2393,7 +2399,7 @@ index d545120a..79760f47 100644 } - (void) orderFront: (id) sender { -@@ -3064,6 +3639,13 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3064,6 +3645,13 @@ static BOOL _allowsAutomaticWindowTabbing; if (didSize) { // Don't redraw everything unless we really have to. [_backgroundView setFrameSize: _frame.size]; @@ -2407,7 +2413,7 @@ index d545120a..79760f47 100644 [_backgroundView setNeedsDisplay: YES]; [self resetCursorRects]; [self saveFrameUsingName: _autosaveFrameName]; -@@ -3260,8 +3842,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3260,8 +3848,7 @@ static BOOL _allowsAutomaticWindowTabbing; mouseWasInside && !mouseIsInside) { [exited addObject: area]; } @@ -2417,7 +2423,7 @@ index d545120a..79760f47 100644 [moved addObject: area]; } if (options & NSTrackingCursorUpdate && !mouseWasInside && -@@ -3459,7 +4040,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3459,7 +4046,7 @@ static BOOL _allowsAutomaticWindowTabbing; } - (CGSubWindow *) _createSubWindowWithFrame: (CGRect) frame { @@ -5191,7 +5197,7 @@ index f4925b66..730d4692 100644 _isModal = [delegate isSheet] || [NSApp modalWindow] == delegate; // FIXME: There should be no need for this. -@@ -254,7 +396,8 @@ static NSData *makeWindowIcon() { +@@ -254,19 +396,96 @@ static NSData *makeWindowIcon() { XNFocusWindow, _window, NULL); @@ -5201,7 +5207,10 @@ index f4925b66..730d4692 100644 [(X11Display *) [NSDisplay currentDisplay] setWindow: self forID: _window]; -@@ -265,8 +408,88 @@ static NSData *makeWindowIcon() { +- // FIXME: Move this call into setStyleMaskInternal below! +- if (_styleMask == NSBorderlessWindowMask) { +- [[self class] removeDecorationForWindow: _window onDisplay: _display]; +- } [self setStyleMaskInternal: _styleMask force: YES]; [self setWindowIcon]; @@ -5291,7 +5300,7 @@ index f4925b66..730d4692 100644 } - (void) dealloc { -@@ -279,24 +502,30 @@ static NSData *makeWindowIcon() { +@@ -279,24 +498,30 @@ static NSData *makeWindowIcon() { return _styleMask; } @@ -5338,7 +5347,7 @@ index f4925b66..730d4692 100644 if (!_mapped) { long states[2]; int states_cnt = 0; -@@ -304,7 +533,7 @@ static NSData *makeWindowIcon() { +@@ -304,7 +529,7 @@ static NSData *makeWindowIcon() { states[states_cnt++] = (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); } @@ -5347,7 +5356,7 @@ index f4925b66..730d4692 100644 states[states_cnt++] = (long) XInternAtom( _display, "_NET_WM_STATE_FULLSCREEN", False); } -@@ -312,14 +541,13 @@ static NSData *makeWindowIcon() { +@@ -312,14 +537,13 @@ static NSData *makeWindowIcon() { XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, 32, PropModeReplace, (const unsigned char *) states, states_cnt); @@ -5364,7 +5373,7 @@ index f4925b66..730d4692 100644 event.data.l[1] = (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); event.data.l[3] = 1; -@@ -327,6 +555,35 @@ static NSData *makeWindowIcon() { +@@ -327,6 +551,47 @@ static NSData *makeWindowIcon() { SubstructureNotifyMask | SubstructureRedirectMask, (XEvent *) &event); } @@ -5373,6 +5382,18 @@ index f4925b66..730d4692 100644 +} + +- (void) setStyleMaskInternal: (NSUInteger) mask force: (BOOL) force { ++ // Cocoa chrome may change after the native window already exists. A hidden, ++ // transparent titlebar is client-drawn chrome, even with the titled bit set. ++ // NSWindowTitleHidden is 1; this SDK exposes titleVisibility as NSInteger. ++ BOOL clientChrome = [_delegate titlebarAppearsTransparent] && ++ [_delegate titleVisibility] == 1; ++ if (!(mask & NSTitledWindowMask) || clientChrome) { ++ [[self class] removeDecorationForWindow: _window onDisplay: _display]; ++ } else { ++ // Return decoration ownership to the WM on the reverse transition. ++ XDeleteProperty(_display, _window, ++ XInternAtom(_display, "_MOTIF_WM_HINTS", False)); ++ } + if (force || (mask & NSWindowStyleMaskResizable) != + (_styleMask & NSWindowStyleMaskResizable)) { + XSizeHints *sh = XAllocSizeHints(); @@ -5400,7 +5421,7 @@ index f4925b66..730d4692 100644 } - (void) setStyleMask: (NSUInteger) mask { -@@ -338,7 +595,6 @@ static NSData *makeWindowIcon() { +@@ -338,7 +603,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -5408,7 +5429,7 @@ index f4925b66..730d4692 100644 struct { unsigned long flags; unsigned long functions; -@@ -356,8 +612,14 @@ static NSData *makeWindowIcon() { +@@ -356,8 +620,14 @@ static NSData *makeWindowIcon() { - (void) ensureMapped { if (!_mapped) { @@ -5423,7 +5444,7 @@ index f4925b66..730d4692 100644 } } -@@ -377,15 +639,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +647,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -5456,7 +5477,7 @@ index f4925b66..730d4692 100644 // TODO: background color } -@@ -393,6 +667,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +675,28 @@ static NSData *makeWindowIcon() { // This is essentially dealloc; we release our contexts // and windows, but unlike dealloc, this method can be called // several times, so set everything to nil/NULL/0. @@ -5485,7 +5506,7 @@ index f4925b66..730d4692 100644 [_context release]; _context = nil; -@@ -421,6 +717,392 @@ static NSData *makeWindowIcon() { +@@ -421,6 +725,392 @@ static NSData *makeWindowIcon() { } } @@ -5878,7 +5899,7 @@ index f4925b66..730d4692 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +1152,32 @@ static NSData *makeWindowIcon() { +@@ -470,14 +1160,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -5912,7 +5933,7 @@ index f4925b66..730d4692 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1188,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1196,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -5938,7 +5959,7 @@ index f4925b66..730d4692 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1262,21 @@ static NSData *makeWindowIcon() { +@@ -543,6 +1270,21 @@ static NSData *makeWindowIcon() { - (void) makeKey { [self ensureMapped]; XRaiseWindow(_display, _window); @@ -5960,7 +5981,7 @@ index f4925b66..730d4692 100644 } - (void) makeMain { -@@ -559,7 +1293,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1301,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -5990,7 +6011,7 @@ index f4925b66..730d4692 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -593,8 +1349,10 @@ static NSData *makeWindowIcon() { +@@ -593,8 +1357,10 @@ static NSData *makeWindowIcon() { if (_cglContext == NULL) { CGLError error; @@ -6003,7 +6024,7 @@ index f4925b66..730d4692 100644 NSLog(@"CGLCreateContext failed at %s %d with error %d", __FILE__, __LINE__, error); -@@ -615,6 +1373,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1381,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -6016,7 +6037,7 @@ index f4925b66..730d4692 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1416,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1424,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -6025,7 +6046,7 @@ index f4925b66..730d4692 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1430,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1438,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -6033,7 +6054,7 @@ index f4925b66..730d4692 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,19 +1475,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,19 +1483,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } diff --git a/tools/build-riot-x11-runtime.py b/tools/build-riot-x11-runtime.py index 3068865..3bef722 100644 --- a/tools/build-riot-x11-runtime.py +++ b/tools/build-riot-x11-runtime.py @@ -48,4 +48,14 @@ subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FL str(project / 'tools/riot-fullscreen-geometry-check.m'), '-o', str(probe)], check=True) subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-lobjc', '-framework', 'Foundation', - '-o', str(project / 'build/riot-fullscreen-geometry-check')], check=True) + '-o', str(project / 'build/riot-fullscreen-geometry-check')], check=True) + +# Query the actual native hints after Cocoa title/style changes, without mapping windows. +probe = project / 'build/riot-window-decorations-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O3', '-march=native', '-mtune=generic', '-UNDEBUG', '-Wno-macro-redefined', '-c', + str(project / 'tools/riot-window-decorations-check.m'), '-o', str(probe)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', + '-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-lobjc', '-framework', 'AppKit', + '-framework', 'Foundation', '-framework', 'CoreGraphics', + '-o', str(project / 'build/riot-window-decorations-check')], check=True) diff --git a/tools/riot-window-decorations-check.m b/tools/riot-window-decorations-check.m new file mode 100644 index 0000000..0bff1f1 --- /dev/null +++ b/tools/riot-window-decorations-check.m @@ -0,0 +1,86 @@ +/* Standalone Cocoa/X11 regression: no client windows, hooks or input. */ +#import +#import +#include +#include +#include +#include + +@protocol DecorationProbeDisplay +- (void *)display; +@end + +@interface NSObject (DecorationProbe) +- (unsigned long)windowHandle; +- (void)recreateXWindowWithAlpha:(BOOL)alpha; +@end + +static BOOL undecorated(NSWindow *window) { + void *xlib = dlopen("/usr/lib/native/libX11.dylib", RTLD_LAZY); + assert(xlib); + Atom (*intern)(Display *, const char *, Bool) = dlsym(xlib, "XInternAtom"); + int (*sync)(Display *, Bool) = dlsym(xlib, "XSync"); + int (*get)(Display *, Window, Atom, long, long, Bool, Atom, + Atom *, int *, unsigned long *, unsigned long *, unsigned char **) = + dlsym(xlib, "XGetWindowProperty"); + int (*freeData)(void *) = dlsym(xlib, "XFree"); + assert(intern && sync && get && freeData); + id native = [window platformWindow]; + Display *display = (Display *)[(id)[NSDisplay currentDisplay] display]; + sync(display, False); + Atom type = None; + int format = 0; + unsigned long count = 0, remaining = 0; + unsigned char *data = NULL; + int status = get(display, [native windowHandle], intern(display, "_MOTIF_WM_HINTS", False), + 0, 5, False, AnyPropertyType, &type, &format, &count, &remaining, &data); + assert(status == Success); + BOOL result = format == 32 && count >= 3 && data && + (((unsigned long *)data)[0] & 2) && ((unsigned long *)data)[2] == 0; + if (data) freeData(data); + dlclose(xlib); + return result; +} + +static void assertDecoration(NSWindow *window, BOOL expected, const char *name) { + BOOL actual = undecorated(window); + printf("WINDOW_DECORATION case=%s undecorated=%d expected=%d\n", name, actual, expected); + assert(actual == expected); +} + +int main(void) { + setbuf(stdout, NULL); + @autoreleasepool { + [NSApplication sharedApplication]; + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(20, 20, 320, 180) + styleMask:NSTitledWindowMask backing:NSBackingStoreBuffered defer:NO]; + assertDecoration(window, NO, "ordinary titled window"); + [window setTitleVisibility:1]; + assertDecoration(window, NO, "hidden title alone preserves frame"); + [window setTitlebarAppearsTransparent:YES]; + assertDecoration(window, YES, "hidden and transparent title removes frame"); + [window setTitleVisibility:0]; + assertDecoration(window, NO, "visible title restores frame"); + [window setTitleVisibility:1]; + [window setTitlebarAppearsTransparent:NO]; + assertDecoration(window, NO, "opaque title restores frame"); + [window setStyleMask:NSTexturedBackgroundWindowMask]; + assertDecoration(window, YES, "untitled with extra flags"); + [window setStyleMask:NSTitledWindowMask]; + assertDecoration(window, NO, "titled style restored"); + [window setStyleMask:NSBorderlessWindowMask]; + assertDecoration(window, YES, "borderless after creation"); + [window setStyleMask:NSTitledWindowMask]; + [window setTitlebarAppearsTransparent:YES]; + [(id)[window platformWindow] recreateXWindowWithAlpha:NO]; + assertDecoration(window, YES, "recreated native window retains client chrome"); + NSWindow *deferred = [[NSWindow alloc] initWithContentRect:NSMakeRect(20, 20, 320, 180) + styleMask:NSTitledWindowMask backing:NSBackingStoreBuffered defer:YES]; + [deferred setTitleVisibility:1]; + [deferred setTitlebarAppearsTransparent:YES]; + assertDecoration(deferred, YES, "properties set before native creation"); + puts("WINDOW_DECORATIONS_CHECK cases=10 pass=1"); + // Windows stay unmapped: the probe never changes compositor focus. + } + return 0; +} From 956f1a156d81b9894fb38ec147697e27c43f88ad Mon Sep 17 00:00:00 2001 From: ekisu Date: Mon, 19 Oct 2026 01:15:03 -0300 Subject: [PATCH 2/8] Implement Cocoa local event monitors for native client dragging --- docs/TESTING.md | 32 ++++++++++ patches/cocotron.patch | 88 ++++++++++++++++++++++++--- tools/build-riot-x11-runtime.py | 9 +++ tools/riot-event-monitors-check.m | 98 +++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 tools/riot-event-monitors-check.m diff --git a/docs/TESTING.md b/docs/TESTING.md index 11a684d..7f8c1b9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -119,6 +119,38 @@ Expected: `CORETEXT_FONT_LIST pass=1` with nonzero family/face counts (306/1124 The font probe reproduces Chromium's installed-family enumeration, mandatory family matching and localized-name lookup with a null language output. It checks ownership of the single-match result and that a nonexistent mandatory family produces no substitute. The resize probe checks backing-layer bounds immediately after frame/bounds changes, including a scaled view and a child layer, without relying on input or coordinate conversion to refresh them. It also verifies that native parent size is current during child layout, and that adding an overlay sibling after the content view does not redirect subsequent content resizing into that overlay. The old sibling selection left content at 1600×900 while stretching the 30×20 overlay to 1280×720; the corrected layout resizes the content and preserves the overlay. Before the fixes, these checks report a stale 1280×720 layer and native parent after a 1600×900 resize. The `FRAME_SIZE_HOOK` check uses a subclass whose `setFrameSize:` publishes its viewport size. It checks growth and shrinkage through parent autoresizing, direct `setFrame:`, and direct `setFrameSize:`. Before component-setter dispatch was corrected only the final direct size setter reached the subclass (one callback); afterward all four updates do, with current geometry visible inside the callback. No probe launches or contacts Riot/League. The user confirmed that the language dropdown and League window resizing work after the final X11 correction (2026-10-15). The practice-tool crash remains unresolved. +### Local event monitors and client dragging + +`python3 tools/build-riot-x11-runtime.py` builds +`build/riot-event-monitors-check`. With patched AppKit installed and clients +closed, run: + +```sh +darling shell env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries \ + DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks \ + /Volumes/SystemRoot/path/to/darling-lol/build/riot-event-monitors-check +``` + +Expected: nine `PASS` lines and exit status 0. This independent probe tests +multiple monitors, mask filtering, removal, consumption, event replacement, +self-removal, and the in-flight snapshot when another monitor is removed. +Monitor order is unspecified and is deliberately not asserted. It dispatches +only synthetic events to its own unmapped window. +The same source runs against native macOS AppKit: + +```sh +clang -fblocks -framework AppKit tools/riot-event-monitors-check.m \ + -o /tmp/riot-event-monitors-check +/tmp/riot-event-monitors-check +``` + +Native reference: macOS 26.6.2. LeagueClientUx uses local +monitors for custom window movement; intercepting a fixed titlebar strip instead +would bypass its own drag policy and risk stealing its window-control clicks. +For a live regression check, drag its header, drag ordinary content (which must +not move the window), minimize and restore it, and check Riot Client header +dragging separately. No match is required. + ### X11 window decorations `python3 tools/build-riot-x11-runtime.py` also builds diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 186b72f..6934ffc 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -126,7 +126,7 @@ index d543e329..e320a3af 100644 { return [NSMethodSignature signatureWithObjCTypes: "v@:"]; diff --git a/AppKit/NSApplication.m b/AppKit/NSApplication.m -index 8e7508e2..6fc86b32 100644 +index 8e7508e2..5abc49b7 100644 --- a/AppKit/NSApplication.m +++ b/AppKit/NSApplication.m @@ -17,6 +17,9 @@ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER @@ -239,10 +239,14 @@ index 8e7508e2..6fc86b32 100644 do { pool = [NSAutoreleasePool new]; -@@ -716,6 +744,12 @@ NSApplication *NSApp = nil; +@@ -716,6 +744,16 @@ NSApplication *NSApp = nil; } - (void) sendEvent: (NSEvent *) event { ++ extern NSEvent *_NSApplyLocalEventMonitors(NSEvent *); ++ event = _NSApplyLocalEventMonitors(event); ++ if (event == nil) ++ return; + static int inputTrace = -1; + if (inputTrace < 0) inputTrace = getenv("RCLOL_INPUT_TRACE") != NULL; + if (inputTrace && ([event type] == NSKeyDown || [event type] == NSLeftMouseDown)) @@ -252,7 +256,7 @@ index 8e7508e2..6fc86b32 100644 if ([event type] == NSKeyDown) { unsigned modifierFlags = [event modifierFlags]; -@@ -742,6 +776,61 @@ NSApplication *NSApp = nil; +@@ -742,6 +780,61 @@ NSApplication *NSApp = nil; do { NSAutoreleasePool *pool = [NSAutoreleasePool new]; @@ -648,10 +652,49 @@ index cacd8999..9422d869 100644 NSInvalidAbstractInvocation(); return nil; diff --git a/AppKit/NSEvent.subproj/NSEvent.m b/AppKit/NSEvent.subproj/NSEvent.m -index d0bad986..d0b1b4aa 100644 +index d0bad986..fecfa9f8 100644 --- a/AppKit/NSEvent.subproj/NSEvent.m +++ b/AppKit/NSEvent.subproj/NSEvent.m -@@ -40,6 +40,10 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@@ -29,6 +29,38 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + #import + #import + #import ++#import ++ ++@interface _NSLocalEventMonitor : NSObject { ++@public ++ NSEventMask _mask; ++ NSEvent * (^_handler)(NSEvent *); ++} ++@end ++ ++@implementation _NSLocalEventMonitor ++- (void) dealloc { ++ [_handler release]; ++ [super dealloc]; ++} ++@end ++ ++static NSMutableArray *_NSLocalEventMonitors; ++ ++/* Snapshot the registrations: changes made by a handler affect subsequent ++ * dispatches, not the event already in flight. Handlers may also enter a nested ++ * event loop, which takes its own snapshot. */ ++NSEvent *_NSApplyLocalEventMonitors(NSEvent *event) { ++ NSArray *monitors = [[_NSLocalEventMonitors copy] autorelease]; ++ for (_NSLocalEventMonitor *monitor in [monitors reverseObjectEnumerator]) { ++ if (event == nil) ++ break; ++ NSUInteger type = [event type]; ++ if (type < sizeof(NSEventMask) * 8 && (monitor->_mask & ((NSEventMask)1 << type))) ++ event = monitor->_handler(event); ++ } ++ return event; ++} + + @implementation NSEvent + +@@ -40,6 +72,10 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ return [[NSDisplay currentDisplay] currentModifierFlags]; } @@ -662,6 +705,34 @@ index d0bad986..d0b1b4aa 100644 - (instancetype) initWithType: (NSEventType) type location: (NSPoint) location modifierFlags: (NSEventModifierFlags) modifierFlags +@@ -350,14 +386,23 @@ static NSTimer *_periodicTimer = nil; + } + + + (id) addLocalMonitorForEventsMatchingMask: (NSEventMask) mask +- handler: (NSEvent * (^)(NSEvent *event)) block ++ handler: (NSEvent * (^)(NSEvent *event)) block + { +- NSUnimplementedMethod(); +- return nil; ++ if (block == nil) ++ return nil; ++ if (_NSLocalEventMonitors == nil) ++ _NSLocalEventMonitors = [NSMutableArray new]; ++ _NSLocalEventMonitor *monitor = [[_NSLocalEventMonitor alloc] init]; ++ monitor->_mask = mask; ++ monitor->_handler = [block copy]; ++ [_NSLocalEventMonitors addObject: monitor]; ++ return [monitor autorelease]; + } + + + (void) removeMonitor: (id) eventMonitor { +- NSUnimplementedMethod(); ++ if ([_NSLocalEventMonitors indexOfObjectIdenticalTo: eventMonitor] != NSNotFound) { ++ [_NSLocalEventMonitors removeObjectIdenticalTo: eventMonitor]; ++ } + } + + @end diff --git a/AppKit/NSEvent.subproj/NSEvent_mouse.m b/AppKit/NSEvent.subproj/NSEvent_mouse.m index 31c65aac..f7d55ec0 100644 --- a/AppKit/NSEvent.subproj/NSEvent_mouse.m @@ -6184,7 +6255,7 @@ index 8860a087..a897e2ce 100644 - (NSArray *) orderedWindowNumbers; diff --git a/AppKit/include/AppKit/NSEvent.h b/AppKit/include/AppKit/NSEvent.h -index 0677f2e7..d16080f4 100644 +index 0677f2e7..b6daef18 100644 --- a/AppKit/include/AppKit/NSEvent.h +++ b/AppKit/include/AppKit/NSEvent.h @@ -83,6 +83,8 @@ typedef NS_ENUM(NSUInteger, NSEventType) { @@ -6196,11 +6267,14 @@ index 0677f2e7..d16080f4 100644 NSAppKitSystem = 100, -@@ -212,6 +214,7 @@ enum { NSApplicationActivated = 0, NSApplicationDeactivated = 1 }; +@@ -212,6 +214,10 @@ enum { NSApplicationActivated = 0, NSApplicationDeactivated = 1 }; + (NSPoint) mouseLocation; + (NSEventModifierFlags) modifierFlags; ++ (NSUInteger) pressedMouseButtons; +++ (id) addLocalMonitorForEventsMatchingMask: (NSEventMask) mask ++ handler: (NSEvent * (^)(NSEvent *event)) block; +++ (void) removeMonitor: (id) eventMonitor; - (instancetype) initWithType: (NSEventType) type location: (NSPoint) location diff --git a/tools/build-riot-x11-runtime.py b/tools/build-riot-x11-runtime.py index 3bef722..192d9f3 100644 --- a/tools/build-riot-x11-runtime.py +++ b/tools/build-riot-x11-runtime.py @@ -59,3 +59,12 @@ subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-lobjc', '-framework', 'AppKit', '-framework', 'Foundation', '-framework', 'CoreGraphics', '-o', str(project / 'build/riot-window-decorations-check')], check=True) + +# Match native AppKit's local event monitor dispatch semantics. +probe = project / 'build/riot-event-monitors-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O3', '-march=native', '-mtune=generic', '-Wno-macro-redefined', '-c', + str(project / 'tools/riot-event-monitors-check.m'), '-o', str(probe)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', + '-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-lobjc', '-framework', 'AppKit', + '-framework', 'Foundation', '-o', str(project / 'build/riot-event-monitors-check')], check=True) diff --git a/tools/riot-event-monitors-check.m b/tools/riot-event-monitors-check.m new file mode 100644 index 0000000..2b21195 --- /dev/null +++ b/tools/riot-event-monitors-check.m @@ -0,0 +1,98 @@ +/* Local event dispatch regression; also runs unchanged against native AppKit. + * No global input, client hooks, account access, or mapped windows. */ +#import + +static NSMutableString *calls; +static int failures; + +@interface MonitorTestWindow : NSWindow +@end +@implementation MonitorTestWindow +- (void) sendEvent: (NSEvent *) event { + [calls appendFormat: @"W%ld", (long)[event type]]; +} +@end + +static void expectCalls(NSString *name, NSString *expected) { + BOOL ok = [calls isEqual: expected]; + printf("%s %s got=%s expected=%s\n", ok ? "PASS" : "FAIL", + [name UTF8String], [calls UTF8String], [expected UTF8String]); + failures += !ok; + [calls setString: @""]; +} + +/* AppKit does not promise an order between independent registrations. Native + * ordering can change with block addresses, even on the same OS build. */ +static void expectBoth(NSString *name) { + NSString *expected = [calls isEqual: @"ABW1"] ? @"ABW1" : @"BAW1"; + expectCalls(name, expected); +} + +int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + calls = [NSMutableString new]; + NSWindow *window = [[MonitorTestWindow alloc] + initWithContentRect: NSMakeRect(100, 100, 300, 200) + styleMask: 0 backing: NSBackingStoreBuffered defer: NO]; + NSEvent *down = [NSEvent mouseEventWithType: NSLeftMouseDown + location: NSMakePoint(10, 10) modifierFlags: 0 timestamp: 0 + windowNumber: [window windowNumber] context: nil + eventNumber: 1 clickCount: 1 pressure: 1]; + NSEvent *up = [NSEvent mouseEventWithType: NSLeftMouseUp + location: NSMakePoint(10, 10) modifierFlags: 0 timestamp: 0 + windowNumber: [window windowNumber] context: nil + eventNumber: 2 clickCount: 1 pressure: 0]; + id a = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"A"]; return event; + }]; + id b = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"B"]; return event; + }]; + [NSApp sendEvent: down]; expectBoth(@"multiple-monitors"); + [NSApp sendEvent: up]; expectCalls(@"mask-filter", @"W2"); + [NSEvent removeMonitor: b]; + [NSApp sendEvent: down]; expectCalls(@"remove", @"AW1"); + [NSEvent removeMonitor: a]; + + b = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"B"]; return nil; + }]; + [NSApp sendEvent: down]; expectCalls(@"consume", @"B"); + [NSEvent removeMonitor: b]; + b = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"B"]; return up; + }]; + [NSApp sendEvent: down]; expectCalls(@"replace-event", @"BW2"); + [NSEvent removeMonitor: b]; + + __block id selfRemoving; + selfRemoving = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"S"]; + [NSEvent removeMonitor: selfRemoving]; return event; + }]; + [NSApp sendEvent: down]; expectCalls(@"self-remove", @"SW1"); + [NSApp sendEvent: down]; expectCalls(@"self-remove-next", @"W1"); + __block id pendingA, pendingB; + pendingA = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"A"]; + [NSEvent removeMonitor: pendingB]; return event; + }]; + pendingB = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"B"]; + [NSEvent removeMonitor: pendingA]; return event; + }]; + [NSApp sendEvent: down]; expectBoth(@"remove-pending-snapshot"); + [NSApp sendEvent: down]; expectCalls(@"remove-pending-next", @"W1"); + [window release]; + [calls release]; + } + return failures ? 1 : 0; +} From c451834cc2da4febb8783a46feae15138a9b28f5 Mon Sep 17 00:00:00 2001 From: ekisu Date: Mon, 19 Oct 2026 01:15:32 -0300 Subject: [PATCH 3/8] Recompile NSEvent in incremental AppKit runtime builds --- tools/build-riot-appkit-runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/build-riot-appkit-runtime.py b/tools/build-riot-appkit-runtime.py index c4a677d..6f64261 100644 --- a/tools/build-riot-appkit-runtime.py +++ b/tools/build-riot-appkit-runtime.py @@ -12,7 +12,7 @@ for line in (framework / 'CMakeFiles/AppKit.dir/flags.make').read_text().splitli name, value = line.split(' = ', 1) flags[name] = shlex.split(value) link = shlex.split((framework / 'CMakeFiles/AppKit.dir/link.txt').read_text()) -for name in ['NSResponder', 'NSApplication', 'NSWindow', 'NSDisplay', 'NSView', 'NSThemeFrame', 'NSTextInput', 'NSBitmapImageRep', 'NSEvent.subproj/NSEvent_mouse']: +for name in ['NSResponder', 'NSApplication', 'NSWindow', 'NSDisplay', 'NSView', 'NSThemeFrame', 'NSTextInput', 'NSBitmapImageRep', 'NSEvent.subproj/NSEvent', 'NSEvent.subproj/NSEvent_mouse']: obj = project / f'build/riot-{Path(name).name}.o' subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], '-O3', '-march=native', '-mtune=generic', '-Wno-macro-redefined', '-c', From 6a3f81cd3f13140318634b7cf7987bddcb98c58f Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:00:41 +0200 Subject: [PATCH 4/8] Keep hidden X11 windows unmapped Restack and makeKey no longer map a window AppKit considers hidden, and setIsVisible: NO always unmaps, so the client's splash stays hidden. Co-Authored-By: Claude Sonnet 5.5 --- patches/cocotron.patch | 87 +++++++++++++++++++++++++-------- tests/test_x11_hidden_window.py | 36 ++++++++++++++ 2 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 tests/test_x11_hidden_window.py diff --git a/patches/cocotron.patch b/patches/cocotron.patch index f506180..b8f2125 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -1690,7 +1690,7 @@ index 8ac2bbdc..a0fb4f37 100644 - (BOOL) performDragOperation: (id) sender { diff --git a/AppKit/NSWindow.m b/AppKit/NSWindow.m -index d545120a..79760f47 100644 +index d545120a..1920e594 100644 --- a/AppKit/NSWindow.m +++ b/AppKit/NSWindow.m @@ -27,6 +27,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ @@ -2462,15 +2462,28 @@ index d545120a..79760f47 100644 [[NSDraggingManager draggingManager] dragImage: image at: location offset: offset -@@ -2536,6 +3116,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2527,15 +3107,19 @@ static BOOL _allowsAutomaticWindowTabbing; + } + + - (void) setIsVisible: (BOOL) visible { +- if (_isVisible == visible) { ++ /* darling-lol: hiding always reaches the platform window, which may be mapped while _isVisible is NO. */ ++ if (_isVisible == visible && visible) { + return; + } ++ BOOL changed = _isVisible != visible; + _isVisible = visible; + if (visible) { + [[self platformWindow] showWindowWithoutActivation]; } else { [[self platformWindow] hideWindow]; } -+ [self postNotificationName: NSWindowDidChangeOcclusionStateNotification]; ++ if (changed) ++ [self postNotificationName: NSWindowDidChangeOcclusionStateNotification]; } - (void) orderFront: (id) sender { -@@ -3064,6 +3645,13 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3064,6 +3648,13 @@ static BOOL _allowsAutomaticWindowTabbing; if (didSize) { // Don't redraw everything unless we really have to. [_backgroundView setFrameSize: _frame.size]; @@ -2484,7 +2497,7 @@ index d545120a..79760f47 100644 [_backgroundView setNeedsDisplay: YES]; [self resetCursorRects]; [self saveFrameUsingName: _autosaveFrameName]; -@@ -3260,8 +3848,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3260,8 +3851,7 @@ static BOOL _allowsAutomaticWindowTabbing; mouseWasInside && !mouseIsInside) { [exited addObject: area]; } @@ -2494,7 +2507,7 @@ index d545120a..79760f47 100644 [moved addObject: area]; } if (options & NSTrackingCursorUpdate && !mouseWasInside && -@@ -3459,7 +4046,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3459,7 +4049,7 @@ static BOOL _allowsAutomaticWindowTabbing; } - (CGSubWindow *) _createSubWindowWithFrame: (CGRect) frame { @@ -5075,7 +5088,7 @@ index bfe3839a..48cfac4c 100644 - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m -index f4925b66..730d4692 100644 +index f4925b66..e458d96f 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -18,11 +18,14 @@ @@ -5571,7 +5584,7 @@ index f4925b66..730d4692 100644 struct { unsigned long flags; unsigned long functions; -@@ -356,8 +620,14 @@ static NSData *makeWindowIcon() { +@@ -356,11 +620,25 @@ static NSData *makeWindowIcon() { - (void) ensureMapped { if (!_mapped) { @@ -5586,7 +5599,18 @@ index f4925b66..730d4692 100644 } } -@@ -377,15 +647,27 @@ static NSData *makeWindowIcon() { ++/* darling-lol: only map for a window AppKit considers visible. NSWindow sets _isVisible before ++ * ordering, so ordered windows still map; a hidden one (like the client's splash) must not be ++ * revived by a later restack or makeKey. */ ++- (void) ensureMappedIfVisible { ++ if (_delegate == nil || [_delegate isVisible]) ++ [self ensureMapped]; ++} ++ + - (void) showWindowWithoutActivation { + [self ensureMapped]; + } +@@ -377,15 +655,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -5619,7 +5643,7 @@ index f4925b66..730d4692 100644 // TODO: background color } -@@ -393,6 +675,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +683,28 @@ static NSData *makeWindowIcon() { // This is essentially dealloc; we release our contexts // and windows, but unlike dealloc, this method can be called // several times, so set everything to nil/NULL/0. @@ -5648,7 +5672,7 @@ index f4925b66..730d4692 100644 [_context release]; _context = nil; -@@ -421,6 +725,392 @@ static NSData *makeWindowIcon() { +@@ -421,6 +733,392 @@ static NSData *makeWindowIcon() { } } @@ -6041,7 +6065,7 @@ index f4925b66..730d4692 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +1160,32 @@ static NSData *makeWindowIcon() { +@@ -470,14 +1168,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -6075,7 +6099,7 @@ index f4925b66..730d4692 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1196,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1204,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -6101,9 +6125,30 @@ index f4925b66..730d4692 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1270,21 @@ static NSData *makeWindowIcon() { +@@ -518,7 +1253,7 @@ static NSData *makeWindowIcon() { + + - (void) placeAboveWindow: (NSInteger) otherNumber { + X11Window *other = [X11Window windowWithWindowNumber: otherNumber]; +- [self ensureMapped]; ++ [self ensureMappedIfVisible]; + + if (!other) { + XRaiseWindow(_display, _window); +@@ -530,7 +1265,7 @@ static NSData *makeWindowIcon() { + + - (void) placeBelowWindow: (NSInteger) otherNumber { + X11Window *other = [X11Window windowWithWindowNumber: otherNumber]; +- [self ensureMapped]; ++ [self ensureMappedIfVisible]; + + if (!other) { + XLowerWindow(_display, _window); +@@ -541,8 +1276,23 @@ static NSData *makeWindowIcon() { + } + - (void) makeKey { - [self ensureMapped]; +- [self ensureMapped]; ++ [self ensureMappedIfVisible]; XRaiseWindow(_display, _window); + /* darling-lol: League's game window is managed (not override-redirect), so keyboard focus is + * the WM's to give. Ask for activation once when the game makes it key; the WM's focus-stealing @@ -6123,7 +6168,7 @@ index f4925b66..730d4692 100644 } - (void) makeMain { -@@ -559,7 +1301,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1309,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -6153,7 +6198,7 @@ index f4925b66..730d4692 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -593,8 +1357,10 @@ static NSData *makeWindowIcon() { +@@ -593,8 +1365,10 @@ static NSData *makeWindowIcon() { if (_cglContext == NULL) { CGLError error; @@ -6166,7 +6211,7 @@ index f4925b66..730d4692 100644 NSLog(@"CGLCreateContext failed at %s %d with error %d", __FILE__, __LINE__, error); -@@ -615,6 +1381,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1389,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -6179,7 +6224,7 @@ index f4925b66..730d4692 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1424,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1432,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -6188,7 +6233,7 @@ index f4925b66..730d4692 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1438,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1446,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -6196,7 +6241,7 @@ index f4925b66..730d4692 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,19 +1483,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,19 +1491,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } diff --git a/tests/test_x11_hidden_window.py b/tests/test_x11_hidden_window.py new file mode 100644 index 0000000..b0397c4 --- /dev/null +++ b/tests/test_x11_hidden_window.py @@ -0,0 +1,36 @@ +"""Check that the patched X11 backend cannot re-map a window AppKit has hidden.""" +import unittest +import test_runtime_compatibility as sources + + +def body(source, signature): + return source.split(signature + ' {', 1)[1].split('\n}', 1)[0] + + +class HiddenWindowChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + + def test_restack_and_make_key_map_only_visible_windows(self): + source = self.patched('cocotron', 'AppKit/X11.backend/X11Window.m') + self.assertIn('[_delegate isVisible]', body(source, '- (void) ensureMappedIfVisible')) + for signature in ('- (void) placeAboveWindow: (NSInteger) otherNumber', + '- (void) placeBelowWindow: (NSInteger) otherNumber', '- (void) makeKey'): + method = body(source, signature) + self.assertIn('[self ensureMappedIfVisible]', method, signature) + self.assertNotIn('[self ensureMapped]', method, signature) + + def test_ordering_marks_window_visible_before_placing_it(self): + source = self.patched('cocotron', 'AppKit/NSWindow.m') + ordering = source.split('relativeTo: (NSInteger) relativeTo\n{', 1)[1].split('\n}', 1)[0] + for call in ('placeAboveWindow', 'placeBelowWindow'): + self.assertLess(ordering.index('_isVisible = YES'), ordering.index(call)) + + def test_hiding_reaches_the_platform_window_when_already_not_visible(self): + source = self.patched('cocotron', 'AppKit/NSWindow.m') + method = body(source, '- (void) setIsVisible: (BOOL) visible') + self.assertIn('_isVisible == visible && visible', method) + self.assertIn('hideWindow', method) + + +if __name__ == '__main__': + unittest.main() From caec5f1dcea9327ee6bb6cdb791290c7c3623f3c Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:49:04 +0200 Subject: [PATCH 5/8] Make NSOpenPanel Open/Cancel always end the modal session Open with no row selected passed nil to -_setFilename:, and NSOpenPanel boxed it in an array literal, so the action threw before the modal session was stopped and the panel stayed up. Both buttons now go through one helper that stops the session only when this panel is the modal window and always orders the panel out; Open accepts the selection (filtered by canChooseFiles/canChooseDirectories and multiple-selection), or the shown directory for directory choosers. Escape cancels. Co-Authored-By: Claude Sonnet 5.5 --- patches/cocotron.patch | 124 +++++++++++++++++++++++++++++++- tests/test_nsopenpanel_close.py | 46 ++++++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 tests/test_nsopenpanel_close.py diff --git a/patches/cocotron.patch b/patches/cocotron.patch index b8f2125..85aa08f 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -11316,13 +11316,80 @@ index 00000000..19cd9ea9 + +@end diff --git a/AppKit/NSSavePanel.subproj/NSSavePanel.m b/AppKit/NSSavePanel.subproj/NSSavePanel.m -index 50c3d59e..5872e594 100644 +index 50c3d59e..be5a8463 100644 --- a/AppKit/NSSavePanel.subproj/NSSavePanel.m +++ b/AppKit/NSSavePanel.subproj/NSSavePanel.m -@@ -124,8 +124,48 @@ static NSSavePanel *_newPanel = nil; - [NSApp stopModalWithCode: NSCancelButton]; +@@ -113,19 +113,108 @@ + } } ++/* Paths of the rows selected in the file list. Safe with no selection. */ ++- (NSArray *) _selectedPaths { ++ NSMutableArray *paths = [NSMutableArray array]; ++ NSIndexSet *rows = [_outlineView selectedRowIndexes]; ++ for (NSUInteger row = [rows firstIndex]; row != NSNotFound; ++ row = [rows indexGreaterThanIndex: row]) { ++ id item = [_outlineView itemAtRow: row]; ++ if ([item isKindOfClass: [NSURL class]] && [[item path] length] > 0) ++ [paths addObject: [item path]]; ++ } ++ return paths; ++} ++ ++/* What the Open/Save button accepts: the selected rows, or with nothing ++ * selected the directory being shown. An empty result keeps the panel up. */ ++- (NSArray *) _acceptablePaths { ++ NSArray *selected = [self _selectedPaths]; ++ if ([selected count] > 0) ++ return selected; ++ if ([_directory length] > 0) ++ return @[ _directory ]; ++ return @[]; ++} ++ ++- (void) _setAcceptedPaths: (NSArray *) paths { ++ [self _setFilename: [paths firstObject]]; ++} ++ ++/* End the modal session this panel is running and take the panel off screen. ++ * The session is stopped only when this panel is the modal window, never ++ * another session that happens to be on top. The panel is hidden here as well ++ * so a dialog whose session cannot be stopped still goes away. */ ++- (void) _dismissWithCode: (NSModalResponse) code { ++ if ([NSApp modalWindow] == self) ++ [NSApp stopModalWithCode: code]; ++ [self orderOut: nil]; ++} ++ + - (IBAction) _selectFile: (id) sender { +- NSURL *url = [_outlineView itemAtRow: [_outlineView selectedRow]]; +- [self _setFilename: [url path]]; +- +- [NSApp stopModalWithCode: NSOKButton]; ++ NSArray *paths = [self _acceptablePaths]; ++ if ([paths count] == 0) ++ return; ++ @try { ++ [self _setAcceptedPaths: paths]; ++ } @catch (NSException *exception) { ++ NSLog(@"NSSavePanel: could not record the chosen file: %@", exception); ++ [self _dismissWithCode: NSCancelButton]; ++ return; ++ } ++ [self _dismissWithCode: NSOKButton]; + } + + - (IBAction) _cancel: (id) sender { +- [NSApp stopModalWithCode: NSCancelButton]; +-} +- ++ [self _dismissWithCode: NSCancelButton]; ++} ++ ++- (void) cancelOperation: (id) sender { ++ [self _cancel: sender]; ++} ++ +// Electron (Riot Client UI) opens its file dialogs through these and waits +// for the handler; run the panel modally and report the result. - (void) beginWithCompletionHandler: (void (^)(NSModalResponse result)) handler { @@ -11368,6 +11435,57 @@ index 50c3d59e..5872e594 100644 + return NO; } + - (NSInteger) runModalForDirectory: (NSString *) directory +diff --git a/AppKit/NSSavePanel.subproj/NSOpenPanel.m b/AppKit/NSSavePanel.subproj/NSOpenPanel.m +index dfd33637..9388b186 100644 +--- a/AppKit/NSSavePanel.subproj/NSOpenPanel.m ++++ b/AppKit/NSSavePanel.subproj/NSOpenPanel.m +@@ -23,6 +23,8 @@ + + @interface NSSavePanel (Private) + - (void) _setFilename: (NSString *) filename; ++- (NSArray *) _selectedPaths; ++- (void) _setAcceptedPaths: (NSArray *) paths; + @end + + @implementation NSOpenPanel +@@ -92,7 +94,36 @@ + + - (void) _setFilename: (NSString *) filename { + [super _setFilename: filename]; +- _filenames = [@[ filename ] retain]; ++ [self _setAcceptedPaths: [filename length] > 0 ? @[ filename ] : @[]]; ++} ++ ++- (void) _setAcceptedPaths: (NSArray *) paths { ++ if (!_allowsMultipleSelection && [paths count] > 1) ++ paths = @[ [paths firstObject] ]; ++ @synchronized(self) { ++ NSArray *copy = [paths copy]; ++ [_filenames release]; ++ _filenames = copy; ++ } ++ [super _setFilename: [paths firstObject]]; ++} ++ ++/* Open only accepts what the panel was configured to choose. With nothing ++ * selected, a directory chooser accepts the directory being shown. */ ++- (NSArray *) _acceptablePaths { ++ NSFileManager *manager = [NSFileManager defaultManager]; ++ NSMutableArray *result = [NSMutableArray array]; ++ for (NSString *path in [self _selectedPaths]) { ++ BOOL isDirectory = NO; ++ if (![manager fileExistsAtPath: path isDirectory: &isDirectory]) ++ continue; ++ if (isDirectory ? _canChooseDirectories : _canChooseFiles) ++ [result addObject: path]; ++ } ++ if ([result count] == 0 && [[self _selectedPaths] count] == 0 && ++ _canChooseDirectories && [[self directory] length] > 0) ++ [result addObject: [self directory]]; ++ return result; + } + - (NSInteger) runModalForDirectory: (NSString *) directory diff --git a/AppKit/include/AppKit/NSSavePanel.h b/AppKit/include/AppKit/NSSavePanel.h index 53bacc46..3c6adfb7 100644 diff --git a/tests/test_nsopenpanel_close.py b/tests/test_nsopenpanel_close.py new file mode 100644 index 0000000..68faed1 --- /dev/null +++ b/tests/test_nsopenpanel_close.py @@ -0,0 +1,46 @@ +"""Check that the patched Cocotron file panels always end their modal session and never throw on Open.""" +import unittest +import test_runtime_compatibility as sources + + +def body(source, signature): + return source.split(signature + ' {', 1)[1].split('\n}', 1)[0] + + +class FilePanelCloseChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + + def save_panel(self): + return self.patched('cocotron', 'AppKit/NSSavePanel.subproj/NSSavePanel.m') + + def open_panel(self): + return self.patched('cocotron', 'AppKit/NSSavePanel.subproj/NSOpenPanel.m') + + def test_both_buttons_dismiss_through_one_helper(self): + source = self.save_panel() + dismiss = body(source, '- (void) _dismissWithCode: (NSModalResponse) code') + self.assertIn('[NSApp modalWindow] == self', dismiss) + self.assertIn('stopModalWithCode: code', dismiss) + self.assertIn('orderOut', dismiss) + self.assertIn('_dismissWithCode: NSCancelButton', body(source, '- (IBAction) _cancel: (id) sender')) + self.assertIn('_dismissWithCode: NSOKButton', body(source, '- (IBAction) _selectFile: (id) sender')) + + def test_open_without_a_selection_cannot_throw(self): + # Nothing selected used to hand nil to -_setFilename:, and NSOpenPanel boxed it in @[ ... ]. + self.assertNotIn('@[ filename ]];', self.open_panel().replace('[filename length] > 0 ? @[ filename ] : @[]', '')) + select = body(self.save_panel(), '- (IBAction) _selectFile: (id) sender') + self.assertNotIn('itemAtRow: [_outlineView selectedRow]', select) + self.assertIn('@catch', select) + self.assertIn('NSCancelButton', select.split('@catch', 1)[1]) + + def test_open_panel_honours_what_it_may_choose(self): + accept = body(self.open_panel(), '- (NSArray *) _acceptablePaths') + self.assertIn('_canChooseDirectories', accept) + self.assertIn('_canChooseFiles', accept) + + def test_escape_cancels(self): + self.assertIn('[self _cancel: sender]', body(self.save_panel(), '- (void) cancelOperation: (id) sender')) + + +if __name__ == '__main__': + unittest.main() From 15b1c49d02597e9197fe9fa4338b0ceab71e2c22 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:19:42 +0200 Subject: [PATCH 6/8] Add standalone NSOpenPanel Open/Cancel probe for issue #85 Co-Authored-By: Claude Sonnet 5.5 --- docs/TESTING.md | 31 ++++++ tools/build-riot-openpanel-check.py | 24 ++++ tools/riot-openpanel-check.m | 166 ++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100755 tools/build-riot-openpanel-check.py create mode 100644 tools/riot-openpanel-check.m diff --git a/docs/TESTING.md b/docs/TESTING.md index 7f8c1b9..3249b96 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -178,6 +178,37 @@ Riot Client also confirmed the no-decoration hint and identical compositor frame and client dimensions (1536×864), instead of the previous 28-pixel titlebar. This validates the Riot Client window, not every LeagueClientUx or game mode. +### NSOpenPanel Open/Cancel (issue #85) + +`python3 tools/build-riot-openpanel-check.py` builds +`build/riot-openpanel-check`. Install the patched AppKit first, with clients and +the game closed. The probe opens its own directory chooser in a fresh `/tmp` +folder containing one subfolder and needs no Riot/League client: + +```sh +P=/Volumes/SystemRoot/path/to/darling-lol/build/riot-openpanel-check +E="env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks" +for m in cancel open-selected open-none; do darling shell $E $P --auto $m; done +darling shell $E $P --interactive +``` + +Each `--auto` mode waits 1.5 s, then clicks the panel's own Cancel or Open +button (selecting the subfolder first for `open-selected`), and checks that +`runModal` returned, the result code and URL, and that the panel is gone. A +15 s watchdog on a separate thread reports a hang. Expected, one line per mode: + +``` +OPENPANEL_CHECK mode=cancel result=PASS code=0 ... +OPENPANEL_CHECK mode=open-selected result=PASS code=1 url=/tmp/openpanel-check.XXXXXX/subfolder ... +OPENPANEL_CHECK mode=open-none result=PASS code=1 url=/tmp/openpanel-check.XXXXXX ... +``` + +A failure prints `result=FAIL ... problems=...` (`HANG` when `runModal` never +returned) and exits non-zero. `--interactive` shows the panel for manual clicks +and prints `OPENPANEL_CHECK mode=interactive result=RETURNED code=... url=...` +once it closes. As with the other probes, check the printed line rather than +only the launcher exit status. + ### Presentation publisher handoff With clients closed, run `build/riot-client-resize-check --handoff` through Darling with the normal runtime overrides. This creates an owned window and two fixture frame slots. The live software owner first supplies a 1280×720 main frame, then switches to a 262×131 popup; another slot supplies the resized main frame without the software flag. The test samples its own window beyond the original frame extent and checks generation colors across growth and shrinkage. The old selector reports black at the 1600×900 sample; the correction passes all four sizes. Repeat with `RCLOL_XSHM=0` and with `DARLING_XSHM_LIBRARY` pointing to the normal transport to cover both paths. The fixtures remove their own frame slots on exit; they do not read client pixels or send input. diff --git a/tools/build-riot-openpanel-check.py b/tools/build-riot-openpanel-check.py new file mode 100755 index 0000000..2bab44b --- /dev/null +++ b/tools/build-riot-openpanel-check.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Build the owned NSOpenPanel modal-session check (issue #85).""" +from pathlib import Path +import json +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +folder = root / 'build/darling-src/build/src/external/cocotron/AppKit' +config = root / 'build/config.json' +sdk = json.loads(config.read_text())['sdk'] if config.exists() else '/usr/local/libexec/darling' +flags = {} +for line in (folder / 'CMakeFiles/X11_backend.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) +obj = root / 'build/riot-openpanel-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-fblocks', '-UNDEBUG', '-Wno-macro-redefined', '-c', str(root / 'tools/riot-openpanel-check.m'), + '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', sdk, str(obj), '-lSystem', '-lobjc', + '-framework', 'AppKit', '-framework', 'CoreFoundation', '-framework', 'Foundation', + '-o', str(root / 'build/riot-openpanel-check')], check=True) diff --git a/tools/riot-openpanel-check.m b/tools/riot-openpanel-check.m new file mode 100644 index 0000000..73b4cba --- /dev/null +++ b/tools/riot-openpanel-check.m @@ -0,0 +1,166 @@ +/* Standalone AppKit regression for issue #85: the panel's own Open/Cancel + * buttons must end runModal. Drives only its own NSOpenPanel; no Riot/League + * client is launched or contacted and no external input is generated. + * + * riot-openpanel-check --auto cancel|open-selected|open-none + * riot-openpanel-check --interactive + */ +#import +#include +#include +#include +#include +#include + +static NSOpenPanel *panel; +static NSString *tempDir, *subDir; +static const char *mode; +static BOOL interactive; +static BOOL panelVisibleAtTrigger; +static NSString *triggerNote = @""; +static const double kTriggerDelay = 1.5, kWatchdog = 15.0; + +static NSButton *findButton(NSView *view, SEL action) { + if ([view isKindOfClass:[NSButton class]] && [(NSButton *)view action] == action) + return (NSButton *)view; + for (NSView *sub in [view subviews]) { + NSButton *found = findButton(sub, action); + if (found) return found; + } + return nil; +} + +static NSOutlineView *findOutline(NSView *view) { + if ([view isKindOfClass:[NSOutlineView class]]) return (NSOutlineView *)view; + for (NSView *sub in [view subviews]) { + NSOutlineView *found = findOutline(sub); + if (found) return found; + } + return nil; +} + +static void press(SEL action, NSString *name) { + NSButton *button = findButton([panel contentView], action); + if (button) { + triggerNote = [NSString stringWithFormat:@"%@ via button", name]; + [button performClick:nil]; + } else { + triggerNote = [NSString stringWithFormat:@"%@ via direct action (button not found)", name]; + [panel performSelector:action withObject:nil]; + } +} + +static void selectSubfolder(void) { + NSOutlineView *outline = findOutline([panel contentView]); + if (!outline) { triggerNote = @"no outline view, subfolder row not found"; return; } + [outline reloadData]; + for (NSInteger row = 0; row < [outline numberOfRows]; row++) { + id item = [outline itemAtRow:row]; + NSString *path = [item isKindOfClass:[NSURL class]] ? [item path] : [item description]; + if ([path isEqualToString:subDir]) { + [outline selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; + return; + } + } + triggerNote = @"subfolder row not found"; +} + +@interface Trigger : NSObject +- (void)fire:(NSTimer *)timer; +@end +@implementation Trigger +- (void)fire:(NSTimer *)timer { + static BOOL done; + if (done) return; + done = YES; + panelVisibleAtTrigger = [panel isVisible]; + if (!strcmp(mode, "cancel")) { + press(@selector(_cancel:), @"cancel"); + } else { + if (!strcmp(mode, "open-selected")) selectSubfolder(); + press(@selector(_selectFile:), @"open"); + } +} +@end + +int main(int argc, char **argv) { + setbuf(stdout, NULL); + if (argc == 2 && !strcmp(argv[1], "--interactive")) { + interactive = YES; + mode = "interactive"; + } else if (argc == 3 && !strcmp(argv[1], "--auto") && + (!strcmp(argv[2], "cancel") || !strcmp(argv[2], "open-selected") || + !strcmp(argv[2], "open-none"))) { + mode = argv[2]; + } else { + fprintf(stderr, "usage: %s --auto cancel|open-selected|open-none | --interactive\n", argv[0]); + return 2; + } + @autoreleasepool { + [NSApplication sharedApplication]; + char templ[] = "/tmp/openpanel-check.XXXXXX"; + tempDir = [[NSString stringWithUTF8String:mkdtemp(templ)] retain]; + subDir = [[tempDir stringByAppendingPathComponent:@"subfolder"] retain]; + [[NSFileManager defaultManager] createDirectoryAtPath:subDir + withIntermediateDirectories:YES attributes:nil error:NULL]; + + panel = [NSOpenPanel openPanel]; + [panel setCanChooseDirectories:YES]; + [panel setCanChooseFiles:NO]; + [panel setAllowsMultipleSelection:NO]; + [panel setDirectory:tempDir]; + + if (!interactive) { + /* Independent of the run loop: a hung runModal still reports. */ + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kWatchdog * NSEC_PER_SEC)), + dispatch_get_global_queue(0, 0), ^{ + printf("OPENPANEL_CHECK mode=%s result=FAIL problems=HANG " + "trigger=\"%s\" visible_at_trigger=%d\n", + mode, [triggerNote UTF8String], panelVisibleAtTrigger); + fflush(stdout); + _exit(3); + }); + Trigger *trigger = [[Trigger alloc] init]; + NSTimer *timer = [NSTimer timerWithTimeInterval:kTriggerDelay target:trigger + selector:@selector(fire:) userInfo:nil repeats:NO]; + [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode]; + [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSModalPanelRunLoopMode]; + } else { + printf("Interactive: pick the folder, or press Open/Cancel on the panel.\n"); + } + + NSInteger code = [panel runModal]; + NSString *path = [[panel URL] path]; + NSArray *urls = [panel URLs]; + BOOL visible = [panel isVisible]; + NSString *shown = path ? path : @"(none)"; + + if (interactive) { + printf("OPENPANEL_CHECK mode=interactive result=RETURNED code=%ld url=%s urls=%lu visible=%d\n", + (long)code, [shown UTF8String], (unsigned long)[urls count], visible); + return 0; + } + + NSMutableArray *problems = [NSMutableArray array]; + BOOL wantOpen = strcmp(mode, "cancel") != 0; + NSString *wantPath = !strcmp(mode, "open-selected") ? subDir : tempDir; + if (!panelVisibleAtTrigger) [problems addObject:@"panel_not_visible_at_trigger"]; + if (wantOpen) { + if (code != NSOKButton) [problems addObject:@"code_not_ok"]; + if (![path isEqualToString:wantPath]) [problems addObject:@"wrong_url"]; + if ([urls count] != 1) [problems addObject:@"wrong_url_count"]; + } else if (code != NSCancelButton) { + [problems addObject:@"code_not_cancel"]; + } + if (visible) [problems addObject:@"still_visible"]; + if ([triggerNote hasSuffix:@"not found"]) [problems addObject:triggerNote]; + BOOL pass = [problems count] == 0; + printf("OPENPANEL_CHECK mode=%s result=%s code=%ld url=%s expected_url=%s visible=%d trigger=\"%s\"%s%s\n", + mode, pass ? "PASS" : "FAIL", (long)code, [shown UTF8String], + wantOpen ? [wantPath UTF8String] : "-", visible, [triggerNote UTF8String], + pass ? "" : " problems=", + pass ? "" : [[problems componentsJoinedByString:@","] UTF8String]); + fflush(stdout); + return pass ? 0 : 1; + } +} From 092c4546993966d32c2e6a805662e25d99d41175 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:28:34 +0200 Subject: [PATCH 7/8] Fix openpanel probe: keep trigger note and panel alive across modal pools Co-Authored-By: Claude Sonnet 5.5 --- tools/riot-openpanel-check.m | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tools/riot-openpanel-check.m b/tools/riot-openpanel-check.m index 73b4cba..7143619 100644 --- a/tools/riot-openpanel-check.m +++ b/tools/riot-openpanel-check.m @@ -17,7 +17,10 @@ static NSString *tempDir, *subDir; static const char *mode; static BOOL interactive; static BOOL panelVisibleAtTrigger; -static NSString *triggerNote = @""; +/* Plain C storage: an autoreleased NSString kept here would be freed by the + * modal loop's pools before the result is reported (and the watchdog thread + * reads it too). */ +static char triggerNote[160] = ""; static const double kTriggerDelay = 1.5, kWatchdog = 15.0; static NSButton *findButton(NSView *view, SEL action) { @@ -39,20 +42,20 @@ static NSOutlineView *findOutline(NSView *view) { return nil; } -static void press(SEL action, NSString *name) { +static void press(SEL action, const char *name) { NSButton *button = findButton([panel contentView], action); if (button) { - triggerNote = [NSString stringWithFormat:@"%@ via button", name]; + snprintf(triggerNote, sizeof triggerNote, "%s via button", name); [button performClick:nil]; } else { - triggerNote = [NSString stringWithFormat:@"%@ via direct action (button not found)", name]; + snprintf(triggerNote, sizeof triggerNote, "%s via direct action (button not found)", name); [panel performSelector:action withObject:nil]; } } static void selectSubfolder(void) { NSOutlineView *outline = findOutline([panel contentView]); - if (!outline) { triggerNote = @"no outline view, subfolder row not found"; return; } + if (!outline) { snprintf(triggerNote, sizeof triggerNote, "no outline view, subfolder row not found"); return; } [outline reloadData]; for (NSInteger row = 0; row < [outline numberOfRows]; row++) { id item = [outline itemAtRow:row]; @@ -62,7 +65,7 @@ static void selectSubfolder(void) { return; } } - triggerNote = @"subfolder row not found"; + snprintf(triggerNote, sizeof triggerNote, "subfolder row not found"); } @interface Trigger : NSObject @@ -75,10 +78,10 @@ static void selectSubfolder(void) { done = YES; panelVisibleAtTrigger = [panel isVisible]; if (!strcmp(mode, "cancel")) { - press(@selector(_cancel:), @"cancel"); + press(@selector(_cancel:), "cancel"); } else { if (!strcmp(mode, "open-selected")) selectSubfolder(); - press(@selector(_selectFile:), @"open"); + press(@selector(_selectFile:), "open"); } } @end @@ -104,7 +107,7 @@ int main(int argc, char **argv) { [[NSFileManager defaultManager] createDirectoryAtPath:subDir withIntermediateDirectories:YES attributes:nil error:NULL]; - panel = [NSOpenPanel openPanel]; + panel = [[NSOpenPanel openPanel] retain]; [panel setCanChooseDirectories:YES]; [panel setCanChooseFiles:NO]; [panel setAllowsMultipleSelection:NO]; @@ -116,7 +119,7 @@ int main(int argc, char **argv) { dispatch_get_global_queue(0, 0), ^{ printf("OPENPANEL_CHECK mode=%s result=FAIL problems=HANG " "trigger=\"%s\" visible_at_trigger=%d\n", - mode, [triggerNote UTF8String], panelVisibleAtTrigger); + mode, triggerNote, panelVisibleAtTrigger); fflush(stdout); _exit(3); }); @@ -153,11 +156,12 @@ int main(int argc, char **argv) { [problems addObject:@"code_not_cancel"]; } if (visible) [problems addObject:@"still_visible"]; - if ([triggerNote hasSuffix:@"not found"]) [problems addObject:triggerNote]; + if (strstr(triggerNote, "not found") && !strstr(triggerNote, "button not found")) + [problems addObject:@"subfolder_row_not_found"]; BOOL pass = [problems count] == 0; printf("OPENPANEL_CHECK mode=%s result=%s code=%ld url=%s expected_url=%s visible=%d trigger=\"%s\"%s%s\n", mode, pass ? "PASS" : "FAIL", (long)code, [shown UTF8String], - wantOpen ? [wantPath UTF8String] : "-", visible, [triggerNote UTF8String], + wantOpen ? [wantPath UTF8String] : "-", visible, triggerNote, pass ? "" : " problems=", pass ? "" : [[problems componentsJoinedByString:@","] UTF8String]); fflush(stdout); From 9efaf4fb8e4bb99a0519375c47c8af3b77524d25 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:33:30 +0200 Subject: [PATCH 8/8] Openpanel probe: expand the tree to the subfolder before selecting, print selection diagnostics Co-Authored-By: Claude Sonnet 5.5 --- tools/riot-openpanel-check.m | 57 ++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/tools/riot-openpanel-check.m b/tools/riot-openpanel-check.m index 7143619..8aac17e 100644 --- a/tools/riot-openpanel-check.m +++ b/tools/riot-openpanel-check.m @@ -7,6 +7,7 @@ */ #import #include +#include #include #include #include @@ -53,19 +54,49 @@ static void press(SEL action, const char *name) { } } -static void selectSubfolder(void) { +static char selectNote[320] = ""; +static BOOL selectionOK = YES; + +static NSString *urlPath(id item) { + NSString *path = [item isKindOfClass:[NSURL class]] ? [item path] : nil; + while ([path length] > 1 && [path hasSuffix:@"/"]) + path = [path substringToIndex:[path length] - 1]; + return path; +} + +/* The panel's tree is rooted at "/" and is not navigated to the directory + * being shown, so expand each component of subDir from the root, then select + * its row. Records diagnostics in selectNote. */ +static BOOL selectSubfolder(void) { NSOutlineView *outline = findOutline([panel contentView]); - if (!outline) { snprintf(triggerNote, sizeof triggerNote, "no outline view, subfolder row not found"); return; } + if (!outline) { snprintf(selectNote, sizeof selectNote, "no_outline_view"); return NO; } [outline reloadData]; - for (NSInteger row = 0; row < [outline numberOfRows]; row++) { - id item = [outline itemAtRow:row]; - NSString *path = [item isKindOfClass:[NSURL class]] ? [item path] : [item description]; - if ([path isEqualToString:subDir]) { - [outline selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; - return; + NSString *prefix = @""; + NSInteger found = -1; + for (NSString *part in [subDir pathComponents]) { + if ([part isEqualToString:@"/"]) continue; + prefix = [prefix stringByAppendingFormat:@"/%@", part]; + found = -1; + for (NSInteger row = 0; row < [outline numberOfRows]; row++) { + if ([urlPath([outline itemAtRow:row]) isEqualToString:prefix]) { found = row; break; } } + if (found < 0) { + snprintf(selectNote, sizeof selectNote, "row_not_found at=%s rows=%ld", + [prefix UTF8String], (long)[outline numberOfRows]); + return NO; + } + if (![prefix isEqualToString:subDir]) [outline expandItem:[outline itemAtRow:found]]; } - snprintf(triggerNote, sizeof triggerNote, "subfolder row not found"); + [outline selectRowIndexes:[NSIndexSet indexSetWithIndex:found] byExtendingSelection:NO]; + NSIndexSet *sel = [outline selectedRowIndexes]; + NSInteger selRow = [sel firstIndex] == NSNotFound ? -1 : (NSInteger)[sel firstIndex]; + id item = selRow >= 0 ? [outline itemAtRow:selRow] : nil; + NSString *itemPath = urlPath(item); + snprintf(selectNote, sizeof selectNote, + "target_row=%ld selected_count=%lu selected_row=%ld item_class=%s item_path=%s", + (long)found, (unsigned long)[sel count], (long)selRow, + item ? class_getName([item class]) : "nil", itemPath ? [itemPath UTF8String] : "(nil)"); + return selRow == found; } @interface Trigger : NSObject @@ -80,7 +111,7 @@ static void selectSubfolder(void) { if (!strcmp(mode, "cancel")) { press(@selector(_cancel:), "cancel"); } else { - if (!strcmp(mode, "open-selected")) selectSubfolder(); + if (!strcmp(mode, "open-selected")) selectionOK = selectSubfolder(); press(@selector(_selectFile:), "open"); } } @@ -156,12 +187,12 @@ int main(int argc, char **argv) { [problems addObject:@"code_not_cancel"]; } if (visible) [problems addObject:@"still_visible"]; - if (strstr(triggerNote, "not found") && !strstr(triggerNote, "button not found")) - [problems addObject:@"subfolder_row_not_found"]; + if (!selectionOK) [problems addObject:@"selection_failed"]; BOOL pass = [problems count] == 0; - printf("OPENPANEL_CHECK mode=%s result=%s code=%ld url=%s expected_url=%s visible=%d trigger=\"%s\"%s%s\n", + printf("OPENPANEL_CHECK mode=%s result=%s code=%ld url=%s expected_url=%s visible=%d trigger=\"%s\"%s%s%s%s%s\n", mode, pass ? "PASS" : "FAIL", (long)code, [shown UTF8String], wantOpen ? [wantPath UTF8String] : "-", visible, triggerNote, + selectNote[0] ? " selection=\"" : "", selectNote, selectNote[0] ? "\"" : "", pass ? "" : " problems=", pass ? "" : [[problems componentsJoinedByString:@","] UTF8String]); fflush(stdout);