Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dec8d60aa3 | ||
|
|
5eb2d0f661 | ||
|
|
5a4e2fbda2 | ||
|
|
92a57308b7 | ||
|
|
8767b73234 | ||
|
|
83cb12eddc | ||
|
|
9b5031aff2 |
@@ -5,10 +5,12 @@ 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`
|
||||
@@ -36,6 +38,10 @@ executable SwiftPM package, no external dependencies (all Apple frameworks).
|
||||
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.
|
||||
- Control Center is special: its extras menu bar lists **every module it owns**,
|
||||
including ones not in the bar, which are parked off-screen at `y≈982`.
|
||||
`enumerate()` drops items whose AX position `y > 60` and de-duplicates by
|
||||
title per app, otherwise the tray fills with identical "Control Center" tiles.
|
||||
- Reading another app's UI is the only TCC permission needed; there is no Screen
|
||||
Recording or Apple Events usage.
|
||||
|
||||
@@ -79,6 +85,59 @@ 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. It is
|
||||
**on by default** (`hideMenuBarItems` defaults to true, then persisted). Its
|
||||
preferred position controls where the hidden/kept boundary lands:
|
||||
|
||||
- Seed **250** (default) puts the boundary just left of Wi-Fi, so every
|
||||
third-party app icon goes to x < 0 (cmux, Kapture, moomoo, WireGuard,
|
||||
TextInputMenuAgent, Now Playing) while **Wi-Fi (x≈1173), Spotlight, Clock,
|
||||
Battery, Control Center and our hamburger stay visible**.
|
||||
- Verified with `--diag-hide` across positions 100–400. Below ~200 Wi-Fi gets
|
||||
hidden too; above ~300 app icons (Now Playing, TextInput) leak back.
|
||||
- `debug.spacerPos` (Double) overrides the seed for tuning without rebuilding.
|
||||
- **Order matters**: `AppDelegate` must call `setupStatusItem()` *before*
|
||||
`MenuBarHider.shared.prepare()`. If the hider contracts the bar first, the
|
||||
hamburger is created into the collapsed space and lands off-screen.
|
||||
- **Wi-Fi/Spotlight/Clock/Battery/Control Center cannot be hidden**: 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. This is a macOS
|
||||
constraint, not a bug.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `AppListModel` is `@MainActor`; the `NSWorkspace` observers hop back with
|
||||
|
||||
@@ -27,11 +27,26 @@ still works.
|
||||
|
||||
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 items to its left
|
||||
off-screen. This is reliable only when SimpleTopBar starts at login (its spacer
|
||||
then sits to the right of later-launched apps' items) and may need re-toggling
|
||||
after items move around. It is off by default and always undone on quit; if the
|
||||
app dies, macOS removes the spacer automatically.
|
||||
created at launch whose length is expanded to push the other icons off-screen so
|
||||
they only live in this panel. It is **on by default** (the choice is
|
||||
remembered), always undone on quit, and if the app dies macOS removes the spacer
|
||||
automatically.
|
||||
|
||||
What it can and cannot hide:
|
||||
|
||||
- It hides **other third-party apps' icons** (cmux, Kapture, WireGuard, input
|
||||
source, Now Playing, …) so only your apps' icons leave the bar.
|
||||
- It **keeps Wi-Fi, Spotlight, Clock, Battery and Control Center**. The spacer's
|
||||
boundary is tuned (preferred position 250) to fall just left of Wi-Fi, so the
|
||||
Apple items on the right stay visible. macOS reserves that far-right area: a
|
||||
third-party item can never be placed to the right of it, so those icons cannot
|
||||
be pushed away even in principle. (Bartender and Ice hit the same wall and
|
||||
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.
|
||||
- Advanced: set `defaults write dev.tzgyn.simpletopbar debug.spacerPos -float <n>`
|
||||
to move the boundary (larger = keep more icons; > ~400 starts leaking app
|
||||
icons back into the bar).
|
||||
|
||||
## Install with Homebrew
|
||||
|
||||
@@ -44,14 +59,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,24 +7,31 @@ struct SimpleTopBarApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra("SimpleTopBar", systemImage: "line.3.horizontal") {
|
||||
TopBarView(
|
||||
model: AppListModel.shared,
|
||||
menuBar: MenuBarItemManager.shared,
|
||||
hider: MenuBarHider.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
|
||||
@@ -36,13 +43,86 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
AppListModel.shared.start()
|
||||
MenuBarItemManager.shared.start()
|
||||
// Create the hamburger before the hider so it is placed in the visible
|
||||
// part of the bar; the hider then contracts the space around it.
|
||||
setupStatusItem()
|
||||
MenuBarHider.shared.prepare()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LoginItem {
|
||||
|
||||
@@ -57,22 +57,34 @@ final class MenuBarItemManager: ObservableObject {
|
||||
items = Self.enumerate()
|
||||
}
|
||||
|
||||
nonisolated static func enumerate() -> [MenuBarItem] {
|
||||
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 pid == ownPID { continue }
|
||||
if !includeOwn && pid == ownPID { continue }
|
||||
let axApp = AXUIElementCreateApplication(pid)
|
||||
guard let bar = extrasMenuBar(of: axApp),
|
||||
let children = children(of: bar) else { continue }
|
||||
|
||||
var seenTitles = Set<String>()
|
||||
for (index, child) in children.enumerated() {
|
||||
// Control Center reports every module it owns, including the
|
||||
// ones not in the menu bar, which are parked off-screen around
|
||||
// y=982. Keep only items actually sitting in the bar.
|
||||
if let position = pointAttribute(child, kAXPositionAttribute), position.y > 60 {
|
||||
continue
|
||||
}
|
||||
|
||||
let title = stringAttribute(child, kAXDescriptionAttribute)
|
||||
?? stringAttribute(child, kAXTitleAttribute)
|
||||
?? app.localizedName
|
||||
?? "Item"
|
||||
// Several Control Center modules share the generic title
|
||||
// "Control Center"; show each distinct title once per app.
|
||||
if !seenTitles.insert(title).inserted { continue }
|
||||
|
||||
found.append(MenuBarItem(
|
||||
id: "\(pid)-\(index)",
|
||||
pid: pid,
|
||||
@@ -126,13 +138,23 @@ final class MenuBarItemManager: ObservableObject {
|
||||
!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
|
||||
/// at launch; expanding its length to a huge value pushes the other apps' icons
|
||||
/// 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.
|
||||
/// panel. On by default; the choice is persisted. Always restored on quit, and
|
||||
/// a crash cleans up automatically because status items die with the process.
|
||||
@MainActor
|
||||
final class MenuBarHider: ObservableObject {
|
||||
static let shared = MenuBarHider()
|
||||
@@ -140,37 +162,55 @@ final class MenuBarHider: ObservableObject {
|
||||
/// Width that pushes the neighbouring items off the visible menu bar.
|
||||
static let hiddenLength: CGFloat = 10_000
|
||||
|
||||
private static let defaultsKey = "hideMenuBarItems"
|
||||
|
||||
@Published private(set) var isHiding = false
|
||||
@Published var enabled = false {
|
||||
@Published var enabled: Bool {
|
||||
didSet {
|
||||
guard enabled != oldValue else { return }
|
||||
UserDefaults.standard.set(enabled, forKey: Self.defaultsKey)
|
||||
apply(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private var spacer: NSStatusItem?
|
||||
|
||||
private init() {}
|
||||
private init() {
|
||||
// Hide by default: the menu bar then shows only SimpleTopBar's hamburger
|
||||
// plus the Apple items macOS will not let us move.
|
||||
enabled = UserDefaults.standard.object(forKey: Self.defaultsKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
/// 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)"
|
||||
// The boundary between "hidden" and "kept" falls just left of Wi-Fi at
|
||||
// ~250, so the other apps hide while Apple's Wi-Fi/Clock/Battery/Control
|
||||
// Center/Spotlight stay visible. `debug.spacerPos` overrides the seed.
|
||||
if let override = UserDefaults.standard.object(forKey: "debug.spacerPos") as? Double {
|
||||
UserDefaults.standard.set(override, forKey: key)
|
||||
} else if UserDefaults.standard.object(forKey: key) == nil {
|
||||
UserDefaults.standard.set(250.0, forKey: key)
|
||||
}
|
||||
let item = NSStatusBar.system.statusItem(withLength: 0)
|
||||
item.autosaveName = name
|
||||
item.button?.isHidden = true
|
||||
spacer = item
|
||||
apply(enabled)
|
||||
}
|
||||
|
||||
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.
|
||||
/// Put the menu bar back the way we found it. Called on quit. Leaves the
|
||||
/// user's preference intact.
|
||||
func restore() {
|
||||
spacer?.length = 0
|
||||
isHiding = false
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
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
|
||||
/// between regular and background applications, then exits 0/1.
|
||||
|
||||
@@ -115,7 +115,7 @@ struct TopBarView: View {
|
||||
.controlSize(.mini)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 6)
|
||||
.help("Best-effort: pushes the real items off the menu bar so they only live here. Reliable when SimpleTopBar starts at login.")
|
||||
.help("Best-effort: pushes other apps' icons off the menu bar so they only live here. Apple's Wi-Fi, Spotlight, Clock, Battery and Control Center stay visible; macOS won't let any third-party app move them.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
VERSION="${1:-0.2.0}"
|
||||
VERSION="${1:-0.2.3}"
|
||||
|
||||
swift build -c release
|
||||
|
||||
|
||||
Reference in New Issue
Block a user