본문 바로가기
C.W.K.
Stream
Lesson 04 of 06 · published

메뉴 막대 유틸리티, 그리고 아무도 못 보는 아이콘

~15 min · macos-citizen, menu-bar, nsstatusitem, lsuielement, diagnostics

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"창을 세기 전에 그 창이 누구 건지부터 물어봐."

Dock 아이콘 없는 앱

가족 유틸리티는 대부분 메뉴 막대에 살아. 텍스트 변환기, 받아쓰기 도구, 음성 엔진과 퀘스트 엔진 런처. 생김새는 다 같아. Info.plist의 LSUIElement = true(아니면 실행 뒤에 같은 일을 하는 코드 setActivationPolicy(.accessory))가 앱을 Dock과 앱 전환기에서 빼고, 주 메뉴도 안 줘. 보이는 건 NSStatusBar.system에서 받은 NSStatusItem 하나가 전부야. 메뉴 막대가 밝든 어둡든 색이 입혀졌든 알아서 따라가게 템플릿 이미지를 쓰고, 이걸 누르면 NSMenu가 열려.

이 구조엔 신경 쓸 부분이 두 가지 따라와. 액세서리 앱은 창이 있다고 활성 앱이 되는 게 아니라서, 메뉴에서 연 설정 창이 맨 앞에 있던 앱 뒤에 뜰 수 있어. 창을 앞으로 가져오고 macOS 14부터 있는 NSApp.activate()를 불러. 이 활성화는 협조형이라, 누가 다른 앱에서 일하는 중이면 macOS가 거절할 수도 있어. AppKit 헤더가 옛 activate(ignoringOtherApps:)를 곧 폐기될 거라고 표시하고 대신 이걸 쓰라고 해. 그리고 NSMenu는 앱이 살아 있는 동안 하나만 두고 항목만 그 안에서 다시 채워. menuWillOpen에서 item.menu에 새 메뉴를 넣으면 이미 열리고 있는 메뉴가 옛 상태로 남아.

돌고 있고, 멀쩡하고, 안 보여

음성 런처가 노치 있는 MacBook에서 완벽하게 돌았어. launchd는 running이라고 했고, 로그도 깨끗했고, 엔진에도 잘 접속됐어. 근데 아이콘이 없었어. CGWindowListCopyWindowInfo로 바깥에서 찔러보니 그 pid의 상태 항목 창이 0개였어. 그래서 항목이 아예 안 만들어진 걸로 보였고, 조사는 한참 실행 경로와 생성 시점 쪽을 헤맸어. 실험해보니 그 후보들은 전부 원인이 아니었어. 실제로는 사실 세 가지가 겹쳐 있었어.

  1. macOS 26에선 상태 항목 창의 주인이 앱이 아니라 Control Center야. 검사가 앱 pid로 걸렀으니 애초에 항목을 찾을 수가 없었어. 그 0은 앱이 아니라 거르는 조건 쪽 사정이었던 거지. macOS 26.6에서 재보니 Control Center의 25번 레이어 창들이 각 항목의 번들 식별자를 창 이름으로 달고 위치까지 갖고 있었어.
  2. 메뉴 막대 관리 앱이 돌고 있었는데, 그 앱은 새 항목을 자기 숨김 구역으로 데려가서 화면 한참 바깥에 세워둬.
  3. 관리 앱을 꺼도 그 앱이 버려두고 간 항목들이 여전히 공간을 차지하며 배치를 밀어냈고, 결국 macOS가 새 항목을 노치 밑에 놨어. isVisible은 true였고, 창 위치는 폭 1728포인트 화면의 딱 한가운데, 픽셀이 하나도 없는 곳이었어.

결국 통한 진단 순서는 이래. 프로세스와 launchd 상태, 바깥 창 검사, 앱에 저장된 NSStatusItem Visible 환경설정, 상태 항목만 달랑 띄우는 열 줄짜리 테스트 앱, 그리고 앱이 자기 항목 위치를 직접 찍기. 그때 돌린 방식으로 답을 볼 수 있었던 건 마지막 단계뿐이었어.

안 보이는 경우를 grep으로 찾게 만들기

끝까지 남은 수정은 아이콘 코드를 바꾸는 게 아니라, 앱이 뜰 때 찍는 로그 한 줄이었어. 항목을 만들고 2초 뒤에 앱이 isVisible과 항목 창의 위치를 찍어. 이제 "돌고 있는데 아이콘이 없어"는 grep 한 번이면 풀리고, 찍힌 위치를 보면 노치 밑을 볼지, 메뉴 막대 관리 앱을 의심할지, 사용자가 숨긴 건지 알 수 있어.

Code

자기 아이콘이 어디 떨어졌는지 보고하는 메뉴 막대 유틸리티의 main.swift·swift
import AppKit

@MainActor
final class StatusBarController: NSObject {
    // Hold the item: you need it to change the icon, to remove it, and to ask where it landed.
    private let item: NSStatusItem
    private let menu = NSMenu()
    private let settingsWindow: NSWindow

    init(settingsWindow: NSWindow) {
        self.settingsWindow = settingsWindow
        item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        super.init()
        item.button?.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Spark")
        item.button?.image?.isTemplate = true   // follows light, dark and tinted menu bars

        let settings = NSMenuItem(title: "Settings…", action: #selector(showSettings), keyEquivalent: ",")
        settings.target = self
        menu.addItem(settings)
        menu.addItem(.separator())
        menu.addItem(NSMenuItem(title: "Quit Spark", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
        item.menu = menu

        // The item's window belongs to Control Center, so a probe filtering on this pid finds nothing. Report it here.
        Task { [weak self] in
            try? await Task.sleep(for: .seconds(2))
            guard let self else { return }
            let frame = self.item.button?.window?.frame
            print("spark: status item isVisible=\(self.item.isVisible) frame=\(frame.map { "\($0)" } ?? "none")")
        }
    }

    @objc private func showSettings() {
        settingsWindow.makeKeyAndOrderFront(nil)
        NSApp.activate()   // an accessory app is not frontmost until it asks
    }
}

let app = NSApplication.shared
app.setActivationPolicy(.accessory)   // the code twin of LSUIElement = true in Info.plist
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 420, height: 280),
                      styleMask: [.titled, .closable], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
let statusBar = StatusBarController(settingsWindow: window)
app.run()

External links

Exercise

위 메뉴 막대 앱을 Swift 6로 main.swift 한 파일에서 빌드해서 돌려. 찍힌 위치를 적고 화면 위치와 비교해. 시작 로그를 늘려서, NSScreen.auxiliaryTopLeftAreaauxiliaryTopRightArea로 계산한 노치 틈과 항목 위치가 겹치는지도 보고하게 해. 둘 중 하나라도 nil이면 "no notch"를 찍고. 마지막으로 따로 만든 스크립트에서 CGWindowListCopyWindowInfo로 창 목록을 뽑아서, 네 앱 pid의 항목이 있는지 확인해. 이어서 네 번들 식별자를 이름으로 단 Control Center 창을 찾아서, 앱이 찍은 위치와 비교해.
Hint
틈은 왼쪽 영역의 maxX에서 오른쪽 영역의 minX까지야. 두 위치 다 주 화면 왼쪽 아래가 원점인 화면 좌표고. 따로 만든 스크립트는 CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID)를 불러서 kCGWindowOwnerPID로 걸러내면 돼. 창 이름은 kCGWindowName이고, Control Center의 상태 항목 창은 kCGWindowLayer 25에 있어.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.