Skip to the content.

[macOS 14] NSMenuToolbarItem와 Palette Menu 사용하기

위 gif 처럼 NSMenuToolbarItem의 submenu에 Palette Menu를 넣는 방법을 소개한다.

방법, 그리고 문제

#import <Cocoa/Cocoa.h>

#pragma mark - MenuToolbarItem
/* NSToolbarItem의 title/image를 cliped menu item과 동일하게 맞춘다. */
@interface MenuToolbarItem : NSMenuToolbarItem
@end
@implementation MenuToolbarItem
- (NSMenuItem *)menuFormRepresentation {
    NSMenuItem *item = [super menuFormRepresentation];
    item.title = self.title;
    item.image = self.image;
    return item;
}
@end


#pragma mark - NSToolbarDelegate

- (NSToolbarItem *)toolbar:(NSToolbar *)toolbar itemForItemIdentifier:(NSToolbarItemIdentifier)itemIdentifier willBeInsertedIntoToolbar:(BOOL)flag {

    /* toolbarItem 생성 */
    MenuToolbarItem *toolbarItem = [[MenuToolbarItem alloc] initWithItemIdentifier:itemIdentifier];
    
    /* 최상위 menu 생성 */
    NSMenu *menu = [[NSMenu alloc] initWithTitle:@"Memu"];
    
    /* Palette Menu 생성 */
    NSMenu *paletteMenu = [NSMenu paletteMenuWithColors:@[NSColor.systemRedColor]
                                                 titles:@[]
                                       selectionHandler:^(NSMenu * _Nonnull menu) {
            
    }];
    
    /* Palette Menu를 띄울 Palette Menu Item 생성 */
    NSMenuItem *paletteMenuItem = [[NSMenuItem alloc] initWithTitle:@"Palette" action:nullptr keyEquivalent:@""];
    paletteMenuItem.submenu = paletteMenu;
    
    /* 최상위 menu에 Palette Menu Item 추가 */
    [menu addItem:paletteMenuItem];
    
    [paletteMenuItem release];
    
    /* toolbarItem에 최상위 menu 할당 */
    toolbarItem.menu = menu;
    [menu release];
    
    return [toolbarItem autorelease];
}

이렇게 하면 이론상 돼야 한다. 하지만 clipped menu를 띄우려는 순간 크래시가 발생한다.

Fatal error: Use of unimplemented initializer 'init(title:action:keyEquivalent:)' for class 'AppKit.NSPaletteMenuItem'

(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BREAKPOINT (code=1, subcode=0x18c8b66ec)
    frame #0: 0x000000018c8b66ec AppKit`___lldb_unnamed_symbol160988 + 44
    frame #1: 0x000000018c5be370 AppKit`-[NSMenuItem copyWithZone:] + 112
    frame #2: 0x000000018c5be254 AppKit`-[NSMenu copyWithZone:] + 348
    frame #3: 0x000000018c5be444 AppKit`-[NSMenuItem copyWithZone:] + 324
    frame #4: 0x000000018c5be254 AppKit`-[NSMenu copyWithZone:] + 348
    frame #5: 0x000000018c5be444 AppKit`-[NSMenuItem copyWithZone:] + 324
    frame #6: 0x000000018cf286a0 AppKit`-[NSToolbarClippedItemsIndicator _computeMenuForClippedItems] + 480
    frame #7: 0x0000000188d78830 CoreFoundation`__CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ + 148
    frame #8: 0x0000000188e0c6a8 CoreFoundation`___CFXRegistrationPost_block_invoke + 88
    frame #9: 0x0000000188e0c5f0 CoreFoundation`_CFXRegistrationPost + 440
    frame #10: 0x0000000188d474b0 CoreFoundation`_CFXNotificationPost + 764

frame #1을 보자

(lldb) p/x (long)0x000000018c5be370 - 112
(long) 0x000000018c5be300
(lldb) disassemble -a 0x000000018c5be300
AppKit`-[NSMenuItem copyWithZone:]:
    /* 생략 */
    0x18c5be32c <+44>:   bl     0x18d2e1f98               ; symbol stub for: objc_opt_class
    0x18c5be330 <+48>:   mov    x2, x22
    0x18c5be334 <+52>:   bl     0x18d754060               ; objc_msgSend$allocWithZone:
    0x18c5be338 <+56>:   mov    x20, x0
    0x18c5be33c <+60>:   mov    x0, x19
    0x18c5be340 <+64>:   bl     0x18d7c9600               ; objc_msgSend$title
    0x18c5be344 <+68>:   mov    x21, x0
    0x18c5be348 <+72>:   mov    x0, x19    0x18c5be34c <+76>:   bl     0x18d750cc0               ; objc_msgSend$action
    /* 생략 */
    0x18c5be36c <+108>:  bl     0x18d780100               ; objc_msgSend$initWithTitle:action:keyEquivalent:
->  0x18c5be370 <+112>:  mov    x20, x0

위 assembly를 보고 -[NSMenuItem copyWithZone:]의 내부 코드를 적자면

@interface NSMenuItem : NSObject <NSCopying>
@end

@implementation NSMenuItem

- (id)copyWithZone:(struct _NSZone *zone) {
    id copy = [[[self class] allocWithZone:zone] initWithTitle:self.title
                                                        action:self.action
                                                 keyEquivalent:self.keyEquivalent];
    
    /* 생략 - 안 중요함 */
    
    return copy;
}

@end

대충 이렇게 되겠다. frame #1에서 -initWithTitle:action:keyEquivalent:를 호출하며, 이는 ___lldb_unnamed_symbol160988 (0x18c5be32c - 44 = 0x18c8b66c0)를 호출한다.

0x18c8b66c0AppKit에서 0x00000001840466c0 만큼의 offset 만큼을 가지며, 이 offset을 dyld_shared_cache_extract_dylibs_progress로 symbolize를 해보면 -[NSPaletteMenuItem initWithTitle:action:keyEquivalent:]인 것을 알 수 있다. (자세한 설명은 생략 ㅎ)

NSPaletteMenuItem-[NSMenu paletteMenuWithColors:titles:selectionHandler:]를 이용하면 아래처럼 내부적으로 생성되는 NSMenuItem이다.

NSMenu *paletteMenu = [NSMenu paletteMenuWithColors:@[NSColor.systemRedColor]
                                             titles:@[]
                                   selectionHandler:^(NSMenu * _Nonnull menu) {
    
}];

[paletteMenu.itemArray enumerateObjectsUsingBlock:^(NSMenuItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
    NSLog(@"%@", obj); // NSPaletteMenuItem
}];

NSPaletteMenuItem의 assembly를 Swift로 변환하면 아래와 같다. 내부적으로 Swift로 구현되어 있다.

@objc(NSPaletteMenuItem)
open class NSPaletteMenuItem: NSMenuItem {
    internal let color: NSColor
    
    public override init(title string: String, action selector: Selector?, keyEquivalent charCode: String) {
        _swift_stdlib_reportUnimplementedInitializer(
            "AppKit.NSPaletteMenuItem".baseAddress!, CInt("AppKit.NSPaletteMenuItem".count),
            "init(title:action:keyEquivalent:)".baseAddress!, CInt("init(title:action:keyEquivalent:)".count),
        )
    }
    
    public required init(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    public init(color: NSColor, image: NSImage, title: String) {
        self.color = color
        super.init(title: title, action: nil, keyEquivalent: "")
    }
    
    internal override _managedItemView: NSView? {
        NSPaletteMenuItemView(item: self)
    }
}

보시다시피 -[NSPaletteMenuItem initWithTitle:action:keyEquivalent:]이 호출되면서 _swift_stdlib_reportUnimplementedInitializer이 호출되어서 크래시가 발생한 것이다.

이 모든 상황을 그림으료 표현하면 아래와 같다.

해결

아래 그림처럼 제어 흐름을 변경하면 될 것 같다. -[NSPaletteMenuItem initWithTitle:action:keyEquivalent:]를 Swizzling해서 _swift_stdlib_reportUnimplementedInitializer 호출하는 것 대신 super를 호출하도록 하고,

-[NSPaletteMenuItem copyWithZone:]을 추가해서 color도 복사되게 하는 것이다.

ㅇㅋ 한 번 해보자

그 전에, 위에서 설명한 것 처럼 NSPaletteMenuItem은 Swift Property를 통해 color를 소유하고 있으므로

@objc(NSPaletteMenuItem)
open class NSPaletteMenuItem: NSMenuItem {
    internal let color: NSColor
    
    /* 생략 */
}

아래처럼 Mirror를 통해 internal property를 가져오는 기능을 구현하고, Objective-C에서 접근할 수 있게 하자. 이렇게 해야 객체를 복사할 때 color도 가져와서 복사할 수 있기 때문!

import AppKit

extension NSMenuItem {
    @objc var color: NSColor? {
        Mirror(reflecting: self)
            .children
            .first { $0.label == "color" }?
            .value as? NSColor
    }
}

이제 위에서 설명한 가설을 코드로 짜보자. 아래처럼 만들어진 registerNSPaletteMenuItemCopyMethod 함수를 대충 +[AppDelegate load]에서 호출해주면 된다.

#import <Cocoa/Cocoa.h>
#import <objc/runtime.h>
#import <objc/message.h>
#import "MiscellaneousAppKit-Swift.h"

namespace NSPaletteMenuItem {
    namespace initWithTitle_action_keyEquivalent {
        id (*original)(id, SEL, id, SEL, id);
        id custom(id self, SEL _cmd, id title, SEL action, id keyEquivalent) {
            struct objc_super superInfo = { self, [self superclass] };
            self = reinterpret_cast<id (*)(struct objc_super *, SEL, id, SEL, id)>(objc_msgSendSuper)(&superInfo, _cmd, title, action, keyEquivalent);
            
            return self;
        }
    }

    namespace copyWithZone {
        id custom(id self, SEL _cmd, struct _NSZone *zone) {
            struct objc_super superInfo = { self, [self superclass] };
            auto copy = reinterpret_cast<NSMenuItem * (*)(struct objc_super *, SEL, struct _NSZone *)>(objc_msgSendSuper)(&superInfo, _cmd, zone);
            
            id color = reinterpret_cast<id (*)(id, SEL)>(objc_msgSend)(self, @selector(color));
            
            // init 중복 호출하면 안 되긴 하는데... color 값을 설정할 방법이 이거 말곤 없어 보임 ㅠㅠ
            // Objective-C Object라면 base pointer + ivar offset로 값 설정할 수 있는데... Swift Object의 내부 구조를 몰라서... ㅠ
            copy = reinterpret_cast<id (*)(id, SEL, id, id, id)>(objc_msgSend)(copy, NSSelectorFromString(@"initWithColor:image:title:"), color, copy.image, copy.title);
            
            return copy;
        }
    }
}

void registerNSPaletteMenuItemCopyMethod() {
    // Swizzling initWithTitle_action_keyEquivalent:
    Method method_1 = class_getInstanceMethod(NSClassFromString(@"NSPaletteMenuItem"), @selector(initWithTitle:action:keyEquivalent:));
    NSPaletteMenuItem::initWithTitle_action_keyEquivalent::original = reinterpret_cast<id (*)(id, SEL, id, SEL, id)>(method_getImplementation(method_1));
    method_setImplementation(method_1, reinterpret_cast<IMP>(NSPaletteMenuItem::initWithTitle_action_keyEquivalent::custom));
    
    // Add copyWithZone:
    class_addMethod(NSClassFromString(@"NSPaletteMenuItem"), @selector(copyWithZone:), reinterpret_cast<IMP>(NSPaletteMenuItem::copyWithZone::custom), nullptr);
}

결론

위처럼 하니 처음에 소개한 gif 사진처럼 잘 된다.

이는 NSPaletteMenuItem에서 NSCopying을 똑바로 지원하지 않아서 생긴 문제다. macOS 내부 버그다.

위 코드는 NSCopying을 지원하게 하는 것이다.