From ee322b43e1ea8aee6a2b5e34bc31da38e2e091ef 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 1/4] 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 70a2feec42fce8f5da7b61b38cf9b01beaa47125 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 2/4] 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 66474a3c76efa9d530fcc8f5aedde0f55574c396 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 3/4] 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 d3adda86fa7276da3d0bd4a66219a8ab969d0406 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 4/4] 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);