diff --git a/README.md b/README.md index 31f07a6..cf6702f 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ collects every other app's menu bar (tray) icons, plus every app running on your Mac, into three sections: - **Menu Bar** — a tray of all other apps' menu bar items, discovered through - the Accessibility API. Click one to open its real menu. Optionally **hide the - items from the menu bar** so they only live inside this panel (best-effort; - see below). + the Accessibility API. It is split into **Apps** and **System** sections. + Click one to open its real menu. Optionally **hide the items from the menu + bar** so they only live inside this panel (best-effort; see below). - **Apps** — regular apps that live in the Dock. - **Background Apps** — accessory/agent apps without a Dock icon. This section is a **dropdown**: it stays collapsed to a single header, and when you expand diff --git a/Sources/SimpleTopBar/MenuBarItems.swift b/Sources/SimpleTopBar/MenuBarItems.swift index cf460b6..75f9337 100644 --- a/Sources/SimpleTopBar/MenuBarItems.swift +++ b/Sources/SimpleTopBar/MenuBarItems.swift @@ -9,6 +9,8 @@ struct MenuBarItem: Identifiable, Equatable { let appName: String let title: String let icon: NSImage + /// True for Apple's own items (Control Center, Spotlight, …). + let isSystem: Bool let element: AXUIElement static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool { @@ -91,6 +93,7 @@ final class MenuBarItemManager: ObservableObject { appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown", title: title, icon: Self.icon(forTitle: title, app: app), + isSystem: Self.isSystemBundle(app.bundleIdentifier), element: child )) } @@ -134,6 +137,16 @@ final class MenuBarItemManager: ObservableObject { return AXUIElementPerformAction(item.element, kAXShowMenuAction as CFString) == .success } + // MARK: - Classification + + /// Apple's own menu bar items (Control Center, Spotlight, the input-source + /// agent, …) live under `com.apple.*` bundles; everything else is a third + /// party app. + nonisolated static func isSystemBundle(_ bundleID: String?) -> Bool { + guard let bundleID else { return false } + return bundleID.hasPrefix("com.apple.") + } + // MARK: - Icons /// Picks an icon for a menu bar item. Control Center owns many unrelated diff --git a/Sources/SimpleTopBar/SelfTest.swift b/Sources/SimpleTopBar/SelfTest.swift index 53dd532..e781077 100644 --- a/Sources/SimpleTopBar/SelfTest.swift +++ b/Sources/SimpleTopBar/SelfTest.swift @@ -35,7 +35,7 @@ func dumpMenuBarItems() { lines.append("count=\(items.count)") for item in items { let pos = MenuBarItemManager.pointAttribute(item.element, kAXPositionAttribute) - lines.append("\(item.appName) | \(item.title) | pid=\(item.pid) | id=\(item.id) | pos=\(pos.map { "\(Int($0.x)),\(Int($0.y))" } ?? "?")") + lines.append("\(item.appName) | \(item.title) | pid=\(item.pid) | id=\(item.id) | system=\(item.isSystem) | pos=\(pos.map { "\(Int($0.x)),\(Int($0.y))" } ?? "?")") } let own = MenuBarItemManager.enumerate(includeOwn: true).filter { $0.pid == ProcessInfo.processInfo.processIdentifier @@ -103,9 +103,9 @@ func runSelfTest() { check(MenuBarItemManager.children(of: systemWide) == nil, "no children on a non-app element") let element = AXUIElementCreateSystemWide() - let first = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", icon: NSImage(), element: element) - let same = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", icon: NSImage(), element: element) - let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", icon: NSImage(), element: element) + let first = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", icon: NSImage(), isSystem: false, element: element) + let same = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", icon: NSImage(), isSystem: false, element: element) + let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", icon: NSImage(), isSystem: false, element: element) check(first == same, "menu bar items compare by id and title") check(first != different, "different ids are not equal") @@ -117,6 +117,11 @@ func runSelfTest() { check(MenuBarItemManager.symbolName(for: "Search") == "magnifyingglass", "search maps to magnifier symbol") check(MenuBarItemManager.symbolName(for: "cmux") == nil, "unknown titles fall back to the app icon") + // Tray sections: Apple bundles are "system", everything else is an app. + check(MenuBarItemManager.isSystemBundle("com.apple.controlcenter"), "apple bundles classify as system") + check(!MenuBarItemManager.isSystemBundle("com.cmux.app"), "third-party bundles classify as apps") + check(!MenuBarItemManager.isSystemBundle(nil), "unknown bundles are treated as apps") + let authorized = AXIsProcessTrusted() print("accessibility authorized=\(authorized)") if authorized { diff --git a/Sources/SimpleTopBar/TopBarView.swift b/Sources/SimpleTopBar/TopBarView.swift index 2eb95a8..424ad3a 100644 --- a/Sources/SimpleTopBar/TopBarView.swift +++ b/Sources/SimpleTopBar/TopBarView.swift @@ -60,23 +60,6 @@ struct TopBarView: View { /// The hamburger tray: every other app's menu bar item lives here. private var menuBarSection: some View { VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 6) { - Text("MENU BAR") - .font(.caption2) - .fontWeight(.semibold) - .foregroundStyle(.secondary) - Spacer() - Text("\(menuBar.items.count)") - .font(.caption2) - .foregroundStyle(.tertiary) - .padding(.horizontal, 6) - .padding(.vertical, 1) - .background(Capsule().fill(Color.secondary.opacity(0.15))) - } - .padding(.horizontal, 12) - .padding(.top, 4) - .padding(.bottom, 4) - if !menuBar.authorized { VStack(alignment: .leading, spacing: 4) { Text("Grant Accessibility to collect and open menu bar items.") @@ -98,12 +81,9 @@ struct TopBarView: View { .padding(.horizontal, 12) .padding(.vertical, 4) } else { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 34), spacing: 6)], spacing: 6) { - ForEach(menuBar.items) { item in - MenuBarTile(item: item, menuBar: menuBar) - } - } - .padding(.horizontal, 12) + trayGroup(title: "Apps", items: appTrayItems) + Divider().padding(.vertical, 2) + trayGroup(title: "System", items: systemTrayItems) } if menuBar.authorized { @@ -120,6 +100,52 @@ struct TopBarView: View { } } + /// Third-party apps' menu bar icons. + private var appTrayItems: [MenuBarItem] { + menuBar.items.filter { !$0.isSystem } + } + + /// Apple's own menu bar items (Control Center, Spotlight, …). + private var systemTrayItems: [MenuBarItem] { + menuBar.items.filter { $0.isSystem } + } + + private func trayGroup(title: String, items: [MenuBarItem]) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Text(title.uppercased()) + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + Spacer() + Text("\(items.count)") + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Capsule().fill(Color.secondary.opacity(0.15))) + } + .padding(.horizontal, 12) + .padding(.top, 4) + .padding(.bottom, 4) + + if items.isEmpty { + Text("None") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.horizontal, 12) + .padding(.bottom, 4) + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 34), spacing: 6)], spacing: 6) { + ForEach(items) { item in + MenuBarTile(item: item, menuBar: menuBar) + } + } + .padding(.horizontal, 12) + } + } + } + private func section(title: String, apps: [RunningApp], emptyText: String) -> some View { VStack(alignment: .leading, spacing: 0) { Text(title.uppercased()) diff --git a/build-app.sh b/build-app.sh index 40497ff..fe4abe4 100755 --- a/build-app.sh +++ b/build-app.sh @@ -2,7 +2,7 @@ set -euo pipefail cd "$(dirname "$0")" -VERSION="${1:-0.4.1}" +VERSION="${1:-0.4.2}" swift build -c release