Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61d2689cbc | ||
|
|
8df3e2cd94 | ||
|
|
d9eddf799f | ||
|
|
dec8d60aa3 | ||
|
|
5eb2d0f661 | ||
|
|
5a4e2fbda2 | ||
|
|
92a57308b7 | ||
|
|
8767b73234 | ||
|
|
83cb12eddc | ||
|
|
9b5031aff2 | ||
|
|
373fda738e |
@@ -5,21 +5,50 @@ executable SwiftPM package, no external dependencies (all Apple frameworks).
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `Sources/SimpleTopBar/App.swift` — `@main` entry. `MenuBarExtra` with
|
- `Sources/SimpleTopBar/App.swift` — `@main` entry using a bare
|
||||||
`.menuBarExtraStyle(.window)` hosting `TopBarView`. `AppDelegate` handles the
|
`Settings { EmptyView() }` scene, because the app owns its status item
|
||||||
`--self-test` flag and the single-instance guard. `LoginItem` wraps
|
manually instead of using `MenuBarExtra`. `AppDelegate.setupStatusItem()`
|
||||||
`SMAppService`.
|
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`
|
- `Sources/SimpleTopBar/AppListModel.swift` — `@MainActor` `ObservableObject`
|
||||||
singleton. `refresh()` reads `NSWorkspace.shared.runningApplications` and
|
singleton. `refresh()` reads `NSWorkspace.shared.runningApplications` and
|
||||||
splits them by `activationPolicy` into `regularApps` and `backgroundApps`
|
splits them by `activationPolicy` into `regularApps` and `backgroundApps`
|
||||||
(both sorted by name). Subscribes to the `NSWorkspace` launch/terminate/
|
(both sorted by name). Subscribes to the `NSWorkspace` launch/terminate/
|
||||||
activate/hide/unhide notifications to refresh automatically.
|
activate/hide/unhide notifications to refresh automatically.
|
||||||
- `Sources/SimpleTopBar/TopBarView.swift` — the panel. `section(...)` renders
|
- `Sources/SimpleTopBar/TopBarView.swift` — the panel. `menuBarSection` is the
|
||||||
the regular apps; `backgroundSection` is the collapsible dropdown for
|
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
|
background apps. `AppRow` shows icon + name + active dot with a right-click
|
||||||
Activate/Hide/Quit menu.
|
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`.
|
- `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.
|
||||||
|
- 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.
|
||||||
|
- Icons: every Control Center module shares the same app icon, so
|
||||||
|
`symbolName(for:)` maps titles (Wi‑Fi, Battery, Now Playing, …) to distinct SF
|
||||||
|
Symbols, falling back to the owning app's icon. Note the non-breaking hyphen
|
||||||
|
in "Wi‑Fi" (`\u{2011}`) is normalised before matching.
|
||||||
|
- Reading another app's UI is the only TCC permission needed; there is no Screen
|
||||||
|
Recording or Apple Events usage.
|
||||||
|
|
||||||
## The background dropdown
|
## The background dropdown
|
||||||
|
|
||||||
This is the core feature. Requirements:
|
This is the core feature. Requirements:
|
||||||
@@ -60,6 +89,65 @@ ad-hoc signing (`-`) if none is present. `LSUIElement` is true → no Dock icon.
|
|||||||
Bump `CFBundleShortVersionString`/`CFBundleVersion` via the `build-app.sh`
|
Bump `CFBundleShortVersionString`/`CFBundleVersion` via the `build-app.sh`
|
||||||
version argument and pass the same version to `release.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.
|
||||||
|
- Pressing a tray tile calls `MenuBarItemManager.press` (`kAXPressAction`, then
|
||||||
|
`kAXShowMenuAction`). Because a hidden item sits off-screen, its menu would
|
||||||
|
open off-screen; `AppDelegate` therefore calls `MenuBarHider.suspend()`
|
||||||
|
(`popoverWillShow`) to reveal items while the panel is open and `resume()`
|
||||||
|
0.6s after `popoverDidClose`. `suspend`/`resume` do not change the user's
|
||||||
|
`enabled` preference.
|
||||||
|
- **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
|
## Gotchas
|
||||||
|
|
||||||
- `AppListModel` is `@MainActor`; the `NSWorkspace` observers hop back with
|
- `AppListModel` is `@MainActor`; the `NSWorkspace` observers hop back with
|
||||||
@@ -68,7 +156,8 @@ version argument and pass the same version to `release.sh`.
|
|||||||
the single-instance guard terminates stale copies with the same bundle id.
|
the single-instance guard terminates stale copies with the same bundle id.
|
||||||
- Reading `activationPolicy` is reliable but must happen on the main thread;
|
- Reading `activationPolicy` is reliable but must happen on the main thread;
|
||||||
keep all `NSWorkspace` access inside the model.
|
keep all `NSWorkspace` access inside the model.
|
||||||
- Don't add Accessibility/Screen Recording permission code: this app only reads
|
- The menu bar tray needs Accessibility permission; do not add Screen Recording
|
||||||
the running-app list, which needs no TCC grants.
|
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
|
- `.gitignore` covers `.build/`, `SimpleTopBar.app/`, `dist/` — build artifacts
|
||||||
should never be committed.
|
should never be committed.
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
# SimpleTopBar
|
# SimpleTopBar
|
||||||
|
|
||||||
A native macOS menu bar app that shows a compact top bar with every app that is
|
A native macOS menu bar app: a **hamburger menu** in the menu bar whose panel
|
||||||
running on your Mac, split into two sections:
|
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.
|
- **Apps** — regular apps that live in the Dock.
|
||||||
- **Background Apps** — accessory/agent apps without a Dock icon. This section
|
- **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
|
is a **dropdown**: it stays collapsed to a single header, and when you expand
|
||||||
@@ -11,6 +16,38 @@ running on your Mac, split into two sections:
|
|||||||
|
|
||||||
Click any row to activate the app, or right-click for Activate / Hide / Quit.
|
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 **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
|
## Install with Homebrew
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -22,14 +59,27 @@ brew install --cask simpletopbar
|
|||||||
The tap is hosted on Gitea, so the full URL is required. Homebrew may print an
|
The tap is hosted on Gitea, so the full URL is required. Homebrew may print an
|
||||||
"Unsupported tap" warning — it's harmless.
|
"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
|
## Build from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
swift build # debug build
|
swift build # debug build
|
||||||
swift run SimpleTopBar # run from terminal (app appears in the menu bar)
|
swift run SimpleTopBar # run from terminal (app appears in the menu bar)
|
||||||
swift run SimpleTopBar --self-test # headless check of the dropdown logic
|
swift run SimpleTopBar --self-test # headless check of the dropdown logic
|
||||||
./build-app.sh 0.1.0 # release build + SimpleTopBar.app bundle + icon + codesign
|
./build-app.sh 0.2.0 # release build + SimpleTopBar.app bundle + icon + codesign
|
||||||
./release.sh 0.1.0 # build-app.sh + dist/SimpleTopBar.zip + sha256
|
./release.sh 0.2.0 # build-app.sh + dist/SimpleTopBar.zip + sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
## Around the Background dropdown
|
## Around the Background dropdown
|
||||||
|
|||||||
@@ -7,20 +7,31 @@ struct SimpleTopBarApp: App {
|
|||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
MenuBarExtra("SimpleTopBar", systemImage: "menubar.rectangle") {
|
// The app is a menu bar extra, so it has no real windows.
|
||||||
TopBarView(model: AppListModel.shared)
|
Settings { EmptyView() }
|
||||||
}
|
|
||||||
.menuBarExtraStyle(.window)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
/// 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, NSPopoverDelegate {
|
||||||
|
private var statusItem: NSStatusItem?
|
||||||
|
private var popover: NSPopover?
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
if CommandLine.arguments.contains("--self-test") {
|
if CommandLine.arguments.contains("--self-test") {
|
||||||
runSelfTest()
|
runSelfTest()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if CommandLine.arguments.contains("--dump-items") {
|
||||||
|
dumpMenuBarItems()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Single instance: stale copies keep a menu bar icon around after an
|
// Single instance: stale copies keep a menu bar icon around after an
|
||||||
// update and swallow clicks meant for the new instance.
|
// update and swallow clicks meant for the new instance.
|
||||||
let myPid = ProcessInfo.processInfo.processIdentifier
|
let myPid = ProcessInfo.processInfo.processIdentifier
|
||||||
@@ -31,6 +42,101 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AppListModel.shared.start()
|
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.delegate = self
|
||||||
|
popover.contentViewController = NSHostingController(
|
||||||
|
rootView: TopBarView(
|
||||||
|
model: AppListModel.shared,
|
||||||
|
menuBar: MenuBarItemManager.shared,
|
||||||
|
hider: MenuBarHider.shared
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.popover = popover
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reveal the hidden menu bar items while the panel is open so pressing a
|
||||||
|
// tray icon opens that app's menu at its real, on-screen position.
|
||||||
|
func popoverWillShow(_ notification: Notification) {
|
||||||
|
MenuBarHider.shared.suspend()
|
||||||
|
}
|
||||||
|
|
||||||
|
func popoverDidClose(_ notification: Notification) {
|
||||||
|
// Small delay so a menu just opened from a tray tile is fully on screen
|
||||||
|
// before the bar contracts again.
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||||
|
MenuBarHider.shared.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,286 @@
|
|||||||
|
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 icon: 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 }
|
||||||
|
|
||||||
|
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,
|
||||||
|
appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
||||||
|
title: title,
|
||||||
|
icon: Self.icon(forTitle: title, app: app),
|
||||||
|
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: - Icons
|
||||||
|
|
||||||
|
/// Picks an icon for a menu bar item. Control Center owns many unrelated
|
||||||
|
/// modules, and they all share the Control Center app icon, so map their
|
||||||
|
/// titles to distinct SF Symbols for readability. Anything unknown falls
|
||||||
|
/// back to the owning app's icon.
|
||||||
|
nonisolated static func icon(forTitle title: String, app: NSRunningApplication) -> NSImage {
|
||||||
|
if let symbol = symbolName(for: title),
|
||||||
|
let image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) {
|
||||||
|
image.isTemplate = true
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
return app.icon ?? NSImage(named: NSImage.applicationIconName) ?? NSImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyword table mapping a menu bar item's title/description to an SF Symbol.
|
||||||
|
nonisolated static func symbolName(for title: String) -> String? {
|
||||||
|
// Control Center uses a non-breaking hyphen in "Wi‑Fi".
|
||||||
|
let text = title.lowercased()
|
||||||
|
.replacingOccurrences(of: "\u{2010}", with: "-")
|
||||||
|
.replacingOccurrences(of: "\u{2011}", with: "-")
|
||||||
|
let table: [(matches: [String], symbol: String)] = [
|
||||||
|
(["wi-fi", "wifi"], "wifi"),
|
||||||
|
(["battery"], "battery.100"),
|
||||||
|
(["clock", "time"], "clock"),
|
||||||
|
(["now playing"], "play.circle"),
|
||||||
|
(["focus"], "moon.fill"),
|
||||||
|
(["sound", "volume", "audio"], "speaker.wave.2"),
|
||||||
|
(["airdrop"], "dot.radiowaves.left.and.right"),
|
||||||
|
(["bluetooth"], "dot.radiowaves.left.and.right"),
|
||||||
|
(["mirroring"], "rectangle.on.rectangle"),
|
||||||
|
(["search", "spotlight"], "magnifyingglass"),
|
||||||
|
(["siri"], "waveform"),
|
||||||
|
(["keyboard"], "keyboard"),
|
||||||
|
(["stage manager"], "rectangle.3.group"),
|
||||||
|
(["brightness", "display"], "sun.max"),
|
||||||
|
(["control center"], "switch.2"),
|
||||||
|
(["notification"], "bell"),
|
||||||
|
(["vpn"], "lock.shield"),
|
||||||
|
]
|
||||||
|
for entry in table where entry.matches.contains(where: { text.contains($0) }) {
|
||||||
|
return entry.symbol
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 other apps' icons
|
||||||
|
/// off the visible menu bar, so they can only be reached through SimpleTopBar's
|
||||||
|
/// 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()
|
||||||
|
|
||||||
|
/// 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: Bool {
|
||||||
|
didSet {
|
||||||
|
guard enabled != oldValue else { return }
|
||||||
|
UserDefaults.standard.set(enabled, forKey: Self.defaultsKey)
|
||||||
|
apply(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var spacer: NSStatusItem?
|
||||||
|
|
||||||
|
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) {
|
||||||
|
spacer?.length = hiding ? Self.hiddenLength : 0
|
||||||
|
isHiding = hiding
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Temporarily reveal the items (e.g. while the panel is open) so pressing
|
||||||
|
/// a tray icon opens its menu at its real, on-screen position. Keeps the
|
||||||
|
/// user's `enabled` preference untouched.
|
||||||
|
func suspend() {
|
||||||
|
guard enabled else { return }
|
||||||
|
spacer?.length = 0
|
||||||
|
isHiding = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore hiding after a `suspend()`.
|
||||||
|
func resume() {
|
||||||
|
guard enabled else { return }
|
||||||
|
spacer?.length = Self.hiddenLength
|
||||||
|
isHiding = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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`.
|
/// Headless verification for `swift run SimpleTopBar --self-test`.
|
||||||
/// Exercises the background-app overflow helpers and reports the live split
|
/// Exercises the background-app overflow helpers and reports the live split
|
||||||
@@ -45,6 +73,33 @@ func runSelfTest() {
|
|||||||
print("running=\(running.count) regular=\(regular) background=\(background)")
|
print("running=\(running.count) regular=\(regular) background=\(background)")
|
||||||
check(regular + background == running.count, "every running app is classified")
|
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", 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)
|
||||||
|
check(first == same, "menu bar items compare by id and title")
|
||||||
|
check(first != different, "different ids are not equal")
|
||||||
|
|
||||||
|
// Control Center items map to distinct symbols instead of one shared icon.
|
||||||
|
check(MenuBarItemManager.symbolName(for: "Wi\u{2011}Fi, connected, 3 bars") == "wifi", "wi-fi maps to wifi symbol")
|
||||||
|
check(MenuBarItemManager.symbolName(for: "Battery") == "battery.100", "battery maps to battery symbol")
|
||||||
|
check(MenuBarItemManager.symbolName(for: "Now Playing") == "play.circle", "now playing maps to play symbol")
|
||||||
|
check(MenuBarItemManager.symbolName(for: "Control Center") == "switch.2", "control center maps to switch symbol")
|
||||||
|
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")
|
||||||
|
|
||||||
|
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)")
|
print(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")
|
||||||
exit(failures == 0 ? 0 : 1)
|
exit(failures == 0 ? 0 : 1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppKit
|
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 {
|
struct TopBarView: View {
|
||||||
@ObservedObject var model: AppListModel
|
@ObservedObject var model: AppListModel
|
||||||
|
@ObservedObject var menuBar: MenuBarItemManager
|
||||||
|
@ObservedObject var hider: MenuBarHider
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -11,6 +13,10 @@ struct TopBarView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
menuBarSection
|
||||||
|
|
||||||
|
Divider().padding(.vertical, 4)
|
||||||
|
|
||||||
section(title: "Apps", apps: model.regularApps, emptyText: "No apps running")
|
section(title: "Apps", apps: model.regularApps, emptyText: "No apps running")
|
||||||
|
|
||||||
Divider().padding(.vertical, 4)
|
Divider().padding(.vertical, 4)
|
||||||
@@ -19,23 +25,28 @@ struct TopBarView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
.frame(maxHeight: 360)
|
.frame(maxHeight: 420)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
footer
|
footer
|
||||||
}
|
}
|
||||||
.frame(width: 320)
|
.frame(width: 320)
|
||||||
|
.onAppear {
|
||||||
|
model.refresh()
|
||||||
|
menuBar.refresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "menubar.rectangle")
|
Image(systemName: "line.3.horizontal")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Text("SimpleTopBar")
|
Text("SimpleTopBar")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
model.refresh()
|
model.refresh()
|
||||||
|
menuBar.refresh()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
@@ -46,6 +57,69 @@ struct TopBarView: View {
|
|||||||
.padding(.vertical, 8)
|
.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 Wi-Fi, Spotlight, Clock, Battery and Control Center stay visible; macOS won't let any third-party app move them.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func section(title: String, apps: [RunningApp], emptyText: String) -> some View {
|
private func section(title: String, apps: [RunningApp], emptyText: String) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text(title.uppercased())
|
Text(title.uppercased())
|
||||||
@@ -163,6 +237,31 @@ struct TopBarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct MenuBarTile: View {
|
||||||
|
let item: MenuBarItem
|
||||||
|
@ObservedObject var menuBar: MenuBarItemManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
menuBar.press(item)
|
||||||
|
} label: {
|
||||||
|
// Preserve the symbol's aspect ratio: forcing wide symbols like the
|
||||||
|
// battery into a square stretches them into a blocky shape.
|
||||||
|
Image(nsImage: item.icon)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.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 {
|
private struct AppRow: View {
|
||||||
let app: RunningApp
|
let app: RunningApp
|
||||||
@ObservedObject var model: AppListModel
|
@ObservedObject var model: AppListModel
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
VERSION="${1:-0.1.0}"
|
VERSION="${1:-0.2.3}"
|
||||||
|
|
||||||
swift build -c release
|
swift build -c release
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user