Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92a57308b7 | ||
|
|
8767b73234 | ||
|
|
83cb12eddc | ||
|
|
9b5031aff2 | ||
|
|
373fda738e |
@@ -5,21 +5,42 @@ executable SwiftPM package, no external dependencies (all Apple frameworks).
|
||||
|
||||
## Architecture
|
||||
|
||||
- `Sources/SimpleTopBar/App.swift` — `@main` entry. `MenuBarExtra` with
|
||||
`.menuBarExtraStyle(.window)` hosting `TopBarView`. `AppDelegate` handles the
|
||||
`--self-test` flag and the single-instance guard. `LoginItem` wraps
|
||||
`SMAppService`.
|
||||
- `Sources/SimpleTopBar/App.swift` — `@main` entry using a bare
|
||||
`Settings { EmptyView() }` scene, because the app owns its status item
|
||||
manually instead of using `MenuBarExtra`. `AppDelegate.setupStatusItem()`
|
||||
creates the `NSStatusItem`, sets `autosaveName = "SimpleTopBar"`, and shows
|
||||
`TopBarView` inside an `NSPopover`. Handles `--self-test`, `--dump-items`,
|
||||
`--diag`, and the single-instance guard. `LoginItem` wraps `SMAppService`.
|
||||
- `Sources/SimpleTopBar/AppListModel.swift` — `@MainActor` `ObservableObject`
|
||||
singleton. `refresh()` reads `NSWorkspace.shared.runningApplications` and
|
||||
splits them by `activationPolicy` into `regularApps` and `backgroundApps`
|
||||
(both sorted by name). Subscribes to the `NSWorkspace` launch/terminate/
|
||||
activate/hide/unhide notifications to refresh automatically.
|
||||
- `Sources/SimpleTopBar/TopBarView.swift` — the panel. `section(...)` renders
|
||||
the regular apps; `backgroundSection` is the collapsible dropdown for
|
||||
- `Sources/SimpleTopBar/TopBarView.swift` — the panel. `menuBarSection` is the
|
||||
hamburger tray of other apps' menu bar items; `section(...)` renders the
|
||||
regular apps; `backgroundSection` is the collapsible dropdown for
|
||||
background apps. `AppRow` shows icon + name + active dot with a right-click
|
||||
Activate/Hide/Quit menu.
|
||||
- `Sources/SimpleTopBar/MenuBarItems.swift` — `MenuBarItemManager` enumerates
|
||||
other apps' status items via the Accessibility API (`kAXExtrasMenuBarAttribute`
|
||||
→ `kAXChildrenAttribute`) and presses them (`kAXPressAction`, falling back to
|
||||
`kAXShowMenuAction`). `MenuBarHider` owns the zero-width spacer `NSStatusItem`
|
||||
used to push items off the menu bar. `AccessibilityPermission` wraps
|
||||
`AXIsProcessTrusted` / `AXIsProcessTrustedWithOptions`.
|
||||
- `Sources/SimpleTopBar/SelfTest.swift` — `runSelfTest()` powers `--self-test`.
|
||||
|
||||
## The menu bar tray (Accessibility)
|
||||
|
||||
- Requires Accessibility permission. `MenuBarItemManager.refresh()` short-circuits
|
||||
to an empty list when `AXIsProcessTrusted()` is false, and `TopBarView` shows a
|
||||
"Grant Accessibility…" button in that state. Never assume permission.
|
||||
- Enumeration must be resilient: `AXUIElementCopyAttributeValue` can fail per-app
|
||||
or per-element, so every helper returns `nil` instead of force-unwrapping. The
|
||||
`enumerate()` routine is `nonisolated static` so `--self-test` can run it.
|
||||
- Items are keyed by `pid-index`; `MenuBarItem` equality ignores the AX element.
|
||||
- Reading another app's UI is the only TCC permission needed; there is no Screen
|
||||
Recording or Apple Events usage.
|
||||
|
||||
## The background dropdown
|
||||
|
||||
This is the core feature. Requirements:
|
||||
@@ -60,6 +81,50 @@ ad-hoc signing (`-`) if none is present. `LSUIElement` is true → no Dock icon.
|
||||
Bump `CFBundleShortVersionString`/`CFBundleVersion` via the `build-app.sh`
|
||||
version argument and pass the same version to `release.sh`.
|
||||
|
||||
## Release process
|
||||
|
||||
1. `./release.sh <version>` — builds, zips `SimpleTopBar.app` to
|
||||
`dist/SimpleTopBar.zip`, and prints its sha256 (it does not tag or upload).
|
||||
2. Commit and push `main`.
|
||||
3. `tea releases create --tag v<version> --asset dist/SimpleTopBar.zip --repo TZGyn/simpletopbar --login gitea.tzgyn.dev`.
|
||||
4. Bump `version`/`sha256` in `~/.cache/homebrew-tap/Casks/simpletopbar.rb`,
|
||||
push, then `git -C /opt/homebrew/Library/Taps/tzgyn/homebrew-tap pull --ff-only`.
|
||||
|
||||
**Always print the upgrade script after any release or version bump** — the user
|
||||
wants it in the final answer every time. Keep it to the cask upgrade commands
|
||||
(see the README release section for the exact block), because the app is
|
||||
installed through the tap.
|
||||
|
||||
## Status item placement (why not MenuBarExtra)
|
||||
|
||||
On a full menu bar, a newly created status item is placed leftmost, which on a
|
||||
notched MacBook is under the notch and therefore invisible. `MenuBarExtra` gives
|
||||
no control over this, so the app creates its own `NSStatusItem` and:
|
||||
|
||||
- Sets `autosaveName = "SimpleTopBar"`.
|
||||
- On first run, writes `"NSStatusItem Preferred Position SimpleTopBar" = 100`
|
||||
into `UserDefaults.standard`. This private-ish key is what macOS reads to
|
||||
place the item, and 100 lands it at the visible right side. Because it is only
|
||||
written when absent, the user's own ⌘-drag wins and persists via the autosave
|
||||
name.
|
||||
|
||||
`--diag` runs full startup and writes every status item's AX position to
|
||||
`/tmp/simpletopbar-items.txt`; use it to verify the icon is not under the notch
|
||||
(x should be ~1200, not ~765).
|
||||
|
||||
## Hiding other menu bar items
|
||||
|
||||
`MenuBarHider` creates a zero-width spacer `NSStatusItem` at launch and grows its
|
||||
length to `hiddenLength` (10_000) to push the icons on its left off-screen.
|
||||
Verified with `--diag-hide`:
|
||||
|
||||
- All third-party apps' icons go to x < 0 (cmux, Kapture, moomoo, WireGuard,
|
||||
TextInputMenuAgent, Now Playing) and so does Wi-Fi.
|
||||
- **Spotlight, Clock, Battery and Control Center stay**: macOS pins them to the
|
||||
far right, and a third-party item can never be inserted to their right, so the
|
||||
spacer has nothing to push them with. Do not try to "fix" this; it is a macOS
|
||||
constraint, not a bug.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `AppListModel` is `@MainActor`; the `NSWorkspace` observers hop back with
|
||||
@@ -68,7 +133,8 @@ version argument and pass the same version to `release.sh`.
|
||||
the single-instance guard terminates stale copies with the same bundle id.
|
||||
- Reading `activationPolicy` is reliable but must happen on the main thread;
|
||||
keep all `NSWorkspace` access inside the model.
|
||||
- Don't add Accessibility/Screen Recording permission code: this app only reads
|
||||
the running-app list, which needs no TCC grants.
|
||||
- The menu bar tray needs Accessibility permission; do not add Screen Recording
|
||||
or Apple Events usage. `MenuBarHider` must always be restored on quit, but
|
||||
because a status item dies with its process, a crash cleans up on its own.
|
||||
- `.gitignore` covers `.build/`, `SimpleTopBar.app/`, `dist/` — build artifacts
|
||||
should never be committed.
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# SimpleTopBar
|
||||
|
||||
A native macOS menu bar app that shows a compact top bar with every app that is
|
||||
running on your Mac, split into two sections:
|
||||
A native macOS menu bar app: a **hamburger menu** in the menu bar whose panel
|
||||
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).
|
||||
- **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
|
||||
@@ -11,6 +16,33 @@ running on your Mac, split into two sections:
|
||||
|
||||
Click any row to activate the app, or right-click for Activate / Hide / Quit.
|
||||
|
||||
## Accessibility permission
|
||||
|
||||
Collecting and opening other apps' menu bar items needs **Accessibility**
|
||||
permission (System Settings → Privacy & Security → Accessibility). The panel
|
||||
prompts for it; without it the Menu Bar section is empty and everything else
|
||||
still works.
|
||||
|
||||
## Hiding menu bar items (best-effort)
|
||||
|
||||
macOS has no public API to move another app's status item. The **Hide items from
|
||||
the menu bar** toggle uses the spacer technique: a zero-width status item is
|
||||
created at launch whose length is expanded to push the other icons off-screen so
|
||||
they only live in this panel. It is off by default and always undone on quit; if
|
||||
the app dies, macOS removes the spacer automatically.
|
||||
|
||||
What it can and cannot hide:
|
||||
|
||||
- It hides **other third-party apps' icons** (and can also catch some Apple
|
||||
items such as Wi-Fi).
|
||||
- It **cannot** hide **Spotlight, Clock, Battery, or Control Center**. macOS
|
||||
reserves the far right of the menu bar for those, and a third-party app cannot
|
||||
place its item to their right, so there is nothing to push them with. (This is
|
||||
the same limit Bartender and Ice hit; they cover the area with their own
|
||||
overlay bar instead.)
|
||||
- Newly launched apps are placed to the left of the spacer, so they are hidden
|
||||
automatically.
|
||||
|
||||
## Install with Homebrew
|
||||
|
||||
```bash
|
||||
@@ -22,14 +54,27 @@ brew install --cask simpletopbar
|
||||
The tap is hosted on Gitea, so the full URL is required. Homebrew may print an
|
||||
"Unsupported tap" warning — it's harmless.
|
||||
|
||||
## Upgrade
|
||||
|
||||
```bash
|
||||
brew update
|
||||
brew upgrade --cask simpletopbar
|
||||
```
|
||||
|
||||
If Gatekeeper still blocks the app after upgrading (it is not notarized yet):
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine /Applications/SimpleTopBar.app
|
||||
```
|
||||
|
||||
## Build from source
|
||||
|
||||
```bash
|
||||
swift build # debug build
|
||||
swift run SimpleTopBar # run from terminal (app appears in the menu bar)
|
||||
swift run SimpleTopBar --self-test # headless check of the dropdown logic
|
||||
./build-app.sh 0.1.0 # release build + SimpleTopBar.app bundle + icon + codesign
|
||||
./release.sh 0.1.0 # build-app.sh + dist/SimpleTopBar.zip + sha256
|
||||
./build-app.sh 0.2.0 # release build + SimpleTopBar.app bundle + icon + codesign
|
||||
./release.sh 0.2.0 # build-app.sh + dist/SimpleTopBar.zip + sha256
|
||||
```
|
||||
|
||||
## Around the Background dropdown
|
||||
|
||||
@@ -7,20 +7,31 @@ struct SimpleTopBarApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra("SimpleTopBar", systemImage: "menubar.rectangle") {
|
||||
TopBarView(model: AppListModel.shared)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
// The app is a menu bar extra, so it has no real windows.
|
||||
Settings { EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the menu bar status item directly (instead of `MenuBarExtra`) so the
|
||||
/// item can be given an `autosaveName` and a persisted preferred position. On a
|
||||
/// full menu bar, a brand new item lands under the notch and is invisible;
|
||||
/// writing `NSStatusItem Preferred Position <name>` into defaults puts it in a
|
||||
/// visible slot, and the autosave name lets the user's own drag persist.
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var statusItem: NSStatusItem?
|
||||
private var popover: NSPopover?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
if CommandLine.arguments.contains("--self-test") {
|
||||
runSelfTest()
|
||||
return
|
||||
}
|
||||
|
||||
if CommandLine.arguments.contains("--dump-items") {
|
||||
dumpMenuBarItems()
|
||||
return
|
||||
}
|
||||
|
||||
// Single instance: stale copies keep a menu bar icon around after an
|
||||
// update and swallow clicks meant for the new instance.
|
||||
let myPid = ProcessInfo.processInfo.processIdentifier
|
||||
@@ -31,6 +42,84 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
AppListModel.shared.start()
|
||||
MenuBarItemManager.shared.start()
|
||||
MenuBarHider.shared.prepare()
|
||||
setupStatusItem()
|
||||
|
||||
if CommandLine.arguments.contains("--diag") {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||
dumpMenuBarItems()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if CommandLine.arguments.contains("--diag-hide") {
|
||||
MenuBarHider.shared.enabled = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
dumpMenuBarItems()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// The tray is empty without Accessibility permission, so surface the
|
||||
// system prompt once at launch instead of waiting for the user to open
|
||||
// the panel and find the button.
|
||||
if !AccessibilityPermission.isGranted,
|
||||
!UserDefaults.standard.bool(forKey: "didRequestAccessibility") {
|
||||
UserDefaults.standard.set(true, forKey: "didRequestAccessibility")
|
||||
AccessibilityPermission.request()
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
// Never leave the menu bar resized behind us.
|
||||
MenuBarHider.shared.restore()
|
||||
}
|
||||
|
||||
private func setupStatusItem() {
|
||||
let name = "SimpleTopBar"
|
||||
let positionKey = "NSStatusItem Preferred Position \(name)"
|
||||
// Only seed the position on first run so the user's own drag wins and
|
||||
// persists through the autosave name.
|
||||
if UserDefaults.standard.object(forKey: positionKey) == nil {
|
||||
UserDefaults.standard.set(100.0, forKey: positionKey)
|
||||
}
|
||||
|
||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
item.autosaveName = name
|
||||
|
||||
if let button = item.button {
|
||||
let image = NSImage(systemSymbolName: "line.3.horizontal", accessibilityDescription: name)
|
||||
image?.isTemplate = true
|
||||
button.image = image
|
||||
button.target = self
|
||||
button.action = #selector(togglePopover)
|
||||
button.setAccessibilityLabel("SimpleTopBar")
|
||||
}
|
||||
statusItem = item
|
||||
|
||||
let popover = NSPopover()
|
||||
popover.behavior = .transient
|
||||
popover.animates = false
|
||||
popover.contentViewController = NSHostingController(
|
||||
rootView: TopBarView(
|
||||
model: AppListModel.shared,
|
||||
menuBar: MenuBarItemManager.shared,
|
||||
hider: MenuBarHider.shared
|
||||
)
|
||||
)
|
||||
self.popover = popover
|
||||
}
|
||||
|
||||
@objc private func togglePopover() {
|
||||
guard let popover, let button = statusItem?.button else { return }
|
||||
if popover.isShown {
|
||||
popover.performClose(nil)
|
||||
} else {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
popover.contentViewController?.view.window?.makeKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import Combine
|
||||
|
||||
/// One live menu bar (status/tray) item discovered via the Accessibility API.
|
||||
struct MenuBarItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
let pid: pid_t
|
||||
let appName: String
|
||||
let title: String
|
||||
let appIcon: NSImage
|
||||
let element: AXUIElement
|
||||
|
||||
static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool {
|
||||
lhs.id == rhs.id && lhs.title == rhs.title
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerates every other app's menu bar items so they can be shown (and
|
||||
/// pressed) from SimpleTopBar's hamburger panel. Requires Accessibility
|
||||
/// permission; without it the list is empty.
|
||||
@MainActor
|
||||
final class MenuBarItemManager: ObservableObject {
|
||||
static let shared = MenuBarItemManager()
|
||||
|
||||
@Published private(set) var items: [MenuBarItem] = []
|
||||
@Published private(set) var authorized = AXIsProcessTrusted()
|
||||
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
private init() {}
|
||||
|
||||
func start() {
|
||||
authorized = AXIsProcessTrusted()
|
||||
refresh()
|
||||
let center = NSWorkspace.shared.notificationCenter
|
||||
let names: [Notification.Name] = [
|
||||
NSWorkspace.didLaunchApplicationNotification,
|
||||
NSWorkspace.didTerminateApplicationNotification,
|
||||
NSWorkspace.didActivateApplicationNotification,
|
||||
NSWorkspace.didHideApplicationNotification,
|
||||
NSWorkspace.didUnhideApplicationNotification,
|
||||
]
|
||||
for name in names {
|
||||
observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
Task { @MainActor in self?.refresh() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
authorized = AXIsProcessTrusted()
|
||||
guard authorized else {
|
||||
items = []
|
||||
return
|
||||
}
|
||||
items = Self.enumerate()
|
||||
}
|
||||
|
||||
nonisolated static func enumerate(includeOwn: Bool = false) -> [MenuBarItem] {
|
||||
let ownPID = ProcessInfo.processInfo.processIdentifier
|
||||
var found: [MenuBarItem] = []
|
||||
|
||||
for app in NSWorkspace.shared.runningApplications {
|
||||
let pid = app.processIdentifier
|
||||
if !includeOwn && pid == ownPID { continue }
|
||||
let axApp = AXUIElementCreateApplication(pid)
|
||||
guard let bar = extrasMenuBar(of: axApp),
|
||||
let children = children(of: bar) else { continue }
|
||||
|
||||
for (index, child) in children.enumerated() {
|
||||
let title = stringAttribute(child, kAXDescriptionAttribute)
|
||||
?? stringAttribute(child, kAXTitleAttribute)
|
||||
?? app.localizedName
|
||||
?? "Item"
|
||||
found.append(MenuBarItem(
|
||||
id: "\(pid)-\(index)",
|
||||
pid: pid,
|
||||
appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
||||
title: title,
|
||||
appIcon: app.icon ?? NSImage(named: NSImage.applicationIconName) ?? NSImage(),
|
||||
element: child
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return found.sorted { a, b in
|
||||
let byApp = a.appName.localizedCaseInsensitiveCompare(b.appName)
|
||||
if byApp == .orderedSame { return a.title.localizedCaseInsensitiveCompare(b.title) == .orderedAscending }
|
||||
return byApp == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the real menu bar item by pressing its accessibility element, so
|
||||
/// its menu/popover appears at its true screen location.
|
||||
@discardableResult
|
||||
func press(_ item: MenuBarItem) -> Bool {
|
||||
if AXUIElementPerformAction(item.element, kAXPressAction as CFString) == .success {
|
||||
return true
|
||||
}
|
||||
return AXUIElementPerformAction(item.element, kAXShowMenuAction as CFString) == .success
|
||||
}
|
||||
|
||||
// MARK: - AX helpers
|
||||
|
||||
nonisolated static func extrasMenuBar(of app: AXUIElement) -> AXUIElement? {
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(app, kAXExtrasMenuBarAttribute as CFString, &value) == .success,
|
||||
let value,
|
||||
CFGetTypeID(value) == AXUIElementGetTypeID() else { return nil }
|
||||
return (value as! AXUIElement)
|
||||
}
|
||||
|
||||
nonisolated static func children(of element: AXUIElement) -> [AXUIElement]? {
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &value) == .success else {
|
||||
return nil
|
||||
}
|
||||
return value as? [AXUIElement]
|
||||
}
|
||||
|
||||
nonisolated static func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success,
|
||||
let string = value as? String,
|
||||
!string.isEmpty else { return nil }
|
||||
return string
|
||||
}
|
||||
|
||||
nonisolated static func pointAttribute(_ element: AXUIElement, _ attribute: String) -> CGPoint? {
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success,
|
||||
let value,
|
||||
CFGetTypeID(value) == AXValueGetTypeID() else { return nil }
|
||||
var point = CGPoint.zero
|
||||
guard AXValueGetValue(value as! AXValue, .cgPoint, &point) else { return nil }
|
||||
return point
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort menu bar item hiding. A zero-width spacer status item is created
|
||||
/// at launch; expanding its length to a huge value pushes the items to its left
|
||||
/// off the visible menu bar, so they can only be reached through SimpleTopBar's
|
||||
/// panel. Reliable only when SimpleTopBar starts at login (its spacer then sits
|
||||
/// to the right of later-launched apps' items). Always restored on quit.
|
||||
@MainActor
|
||||
final class MenuBarHider: ObservableObject {
|
||||
static let shared = MenuBarHider()
|
||||
|
||||
/// Width that pushes the neighbouring items off the visible menu bar.
|
||||
static let hiddenLength: CGFloat = 10_000
|
||||
|
||||
@Published private(set) var isHiding = false
|
||||
@Published var enabled = false {
|
||||
didSet {
|
||||
guard enabled != oldValue else { return }
|
||||
apply(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private var spacer: NSStatusItem?
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Create the spacer early (at launch) so it lands as far right as possible.
|
||||
func prepare() {
|
||||
guard spacer == nil else { return }
|
||||
let name = "SimpleTopBarHider"
|
||||
let key = "NSStatusItem Preferred Position \(name)"
|
||||
// Seed just left of the hamburger so growing the spacer pushes the
|
||||
// third-party icons on its left off-screen while ours stays put.
|
||||
if UserDefaults.standard.object(forKey: key) == nil {
|
||||
UserDefaults.standard.set(150.0, forKey: key)
|
||||
}
|
||||
let item = NSStatusBar.system.statusItem(withLength: 0)
|
||||
item.autosaveName = name
|
||||
item.button?.isHidden = true
|
||||
spacer = item
|
||||
}
|
||||
|
||||
private func apply(_ hiding: Bool) {
|
||||
prepare()
|
||||
spacer?.length = hiding ? Self.hiddenLength : 0
|
||||
isHiding = hiding
|
||||
}
|
||||
|
||||
/// Put the menu bar back the way we found it. Called on quit.
|
||||
func restore() {
|
||||
spacer?.length = 0
|
||||
isHiding = false
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
enum AccessibilityPermission {
|
||||
static var isGranted: Bool { AXIsProcessTrusted() }
|
||||
|
||||
static func request() {
|
||||
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
|
||||
_ = AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,32 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
|
||||
/// Diagnostic used by `open SimpleTopBar.app --args --dump-items`. Writes the
|
||||
/// live Accessibility view of the menu bar to /tmp so it can be inspected
|
||||
/// without a GUI, then exits.
|
||||
func dumpMenuBarItems() {
|
||||
var lines: [String] = []
|
||||
lines.append("bundle=\(Bundle.main.bundleIdentifier ?? "?")")
|
||||
lines.append("authorized=\(AXIsProcessTrusted())")
|
||||
let items = MenuBarItemManager.enumerate()
|
||||
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))" } ?? "?")")
|
||||
}
|
||||
let own = MenuBarItemManager.enumerate(includeOwn: true).filter {
|
||||
$0.pid == ProcessInfo.processInfo.processIdentifier
|
||||
}
|
||||
lines.append("own-status-items=\(own.count)")
|
||||
for item in own {
|
||||
let pos = MenuBarItemManager.pointAttribute(item.element, kAXPositionAttribute)
|
||||
lines.append("OWN \(item.appName) | \(item.title) | id=\(item.id) | pos=\(pos.map { "\(Int($0.x)),\(Int($0.y))" } ?? "?")")
|
||||
}
|
||||
let text = lines.joined(separator: "\n")
|
||||
try? text.write(toFile: "/tmp/simpletopbar-items.txt", atomically: true, encoding: .utf8)
|
||||
print(text)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
/// Headless verification for `swift run SimpleTopBar --self-test`.
|
||||
/// Exercises the background-app overflow helpers and reports the live split
|
||||
@@ -45,6 +73,25 @@ func runSelfTest() {
|
||||
print("running=\(running.count) regular=\(regular) background=\(background)")
|
||||
check(regular + background == running.count, "every running app is classified")
|
||||
|
||||
// Menu bar item discovery degrades gracefully when it cannot read another
|
||||
// app's UI (accessibility not granted, or the element has no extras bar).
|
||||
let systemWide = AXUIElementCreateSystemWide()
|
||||
check(MenuBarItemManager.extrasMenuBar(of: systemWide) == nil, "no extras bar on a non-app element")
|
||||
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", appIcon: NSImage(), element: element)
|
||||
let same = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", appIcon: NSImage(), element: element)
|
||||
let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", appIcon: NSImage(), element: element)
|
||||
check(first == same, "menu bar items compare by id and title")
|
||||
check(first != different, "different ids are not equal")
|
||||
|
||||
let authorized = AXIsProcessTrusted()
|
||||
print("accessibility authorized=\(authorized)")
|
||||
if authorized {
|
||||
print("menu bar items discovered=\(MenuBarItemManager.enumerate().count)")
|
||||
}
|
||||
|
||||
print(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")
|
||||
exit(failures == 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// The top bar panel shown from the menu bar icon.
|
||||
/// The top bar panel shown from the menu bar hamburger icon.
|
||||
struct TopBarView: View {
|
||||
@ObservedObject var model: AppListModel
|
||||
@ObservedObject var menuBar: MenuBarItemManager
|
||||
@ObservedObject var hider: MenuBarHider
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -11,6 +13,10 @@ struct TopBarView: View {
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
menuBarSection
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
section(title: "Apps", apps: model.regularApps, emptyText: "No apps running")
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
@@ -19,23 +25,28 @@ struct TopBarView: View {
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.frame(maxHeight: 360)
|
||||
.frame(maxHeight: 420)
|
||||
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(width: 320)
|
||||
.onAppear {
|
||||
model.refresh()
|
||||
menuBar.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "menubar.rectangle")
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("SimpleTopBar")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
model.refresh()
|
||||
menuBar.refresh()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
@@ -46,6 +57,69 @@ struct TopBarView: View {
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
/// 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.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Grant Accessibility…") {
|
||||
AccessibilityPermission.request()
|
||||
menuBar.refresh()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
} else if menuBar.items.isEmpty {
|
||||
Text("No menu bar items found")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.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)
|
||||
}
|
||||
|
||||
if menuBar.authorized {
|
||||
Toggle(isOn: $hider.enabled) {
|
||||
Text("Hide items from the menu bar")
|
||||
.font(.caption)
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.mini)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 6)
|
||||
.help("Best-effort: pushes other apps' icons off the menu bar so they only live here. Apple's Spotlight, Clock, Battery and Control Center stay pinned on the right and cannot be moved by any third-party app.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func section(title: String, apps: [RunningApp], emptyText: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title.uppercased())
|
||||
@@ -163,6 +237,28 @@ struct TopBarView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MenuBarTile: View {
|
||||
let item: MenuBarItem
|
||||
@ObservedObject var menuBar: MenuBarItemManager
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
menuBar.press(item)
|
||||
} label: {
|
||||
Image(nsImage: item.appIcon)
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
.padding(6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.secondary.opacity(0.12))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("\(item.appName) — \(item.title)")
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppRow: View {
|
||||
let app: RunningApp
|
||||
@ObservedObject var model: AppListModel
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
VERSION="${1:-0.1.0}"
|
||||
VERSION="${1:-0.2.3}"
|
||||
|
||||
swift build -c release
|
||||
|
||||
|
||||
Reference in New Issue
Block a user