Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e57efe312 | ||
|
|
2312e09105 | ||
|
|
9c5416d27e | ||
|
|
61d2689cbc | ||
|
|
8df3e2cd94 | ||
|
|
d9eddf799f | ||
|
|
dec8d60aa3 | ||
|
|
5eb2d0f661 | ||
|
|
5a4e2fbda2 | ||
|
|
92a57308b7 | ||
|
|
8767b73234 |
@@ -5,10 +5,12 @@ 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`
|
||||||
@@ -36,6 +38,14 @@ executable SwiftPM package, no external dependencies (all Apple frameworks).
|
|||||||
or per-element, so every helper returns `nil` instead of force-unwrapping. The
|
or per-element, so every helper returns `nil` instead of force-unwrapping. The
|
||||||
`enumerate()` routine is `nonisolated static` so `--self-test` can run it.
|
`enumerate()` routine is `nonisolated static` so `--self-test` can run it.
|
||||||
- Items are keyed by `pid-index`; `MenuBarItem` equality ignores the AX element.
|
- 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
|
- Reading another app's UI is the only TCC permission needed; there is no Screen
|
||||||
Recording or Apple Events usage.
|
Recording or Apple Events usage.
|
||||||
|
|
||||||
@@ -93,6 +103,50 @@ 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
|
(see the README release section for the exact block), because the app is
|
||||||
installed through the tap.
|
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 (cover)
|
||||||
|
|
||||||
|
`MenuBarHider` drives `MenuBarCover`, which hides other apps' icons by drawing
|
||||||
|
our own frosted window ("cover") over them instead of moving them off-screen.
|
||||||
|
Because the icons keep their real positions, `press` opens their menus in place
|
||||||
|
with no reveal. Verified with `--diag-cover` (cover windows at level 26) and
|
||||||
|
`--diag-press` (the WireGuard menu opens at a positive x, not off-screen).
|
||||||
|
|
||||||
|
- `MenuBarCover.shouldHide` covers everything except the Apple keep-list
|
||||||
|
(Wi-Fi, Battery, Clock, Control Center, Spotlight/Search).
|
||||||
|
- `runs(from:)` merges adjacent item frames into contiguous cover windows;
|
||||||
|
`subtracting(_:from:)` carves out the hamburger so it is never covered
|
||||||
|
(`MenuBarItemManager.refresh` passes `avoid:` = our own item's frame).
|
||||||
|
- Frames come from AX (`kAXPositionAttribute` + `kAXSizeAttribute`) with a
|
||||||
|
**top-left origin**; `windowFrame(for:primaryHeight:)` flips Y for NSWindow.
|
||||||
|
- The cover window level is 26 (just above status items at 25, below menus at
|
||||||
|
~101), so opened menus draw on top of it.
|
||||||
|
- `ignoresMouseEvents = false` makes the covered strip swallow clicks so the
|
||||||
|
hidden icons cannot be clicked through.
|
||||||
|
|
||||||
|
### Legacy: off-screen spacer
|
||||||
|
|
||||||
|
An earlier approach used a zero-width spacer `NSStatusItem` grown to width
|
||||||
|
10_000 to push icons off the left edge. It is no longer used because it required
|
||||||
|
a visible reveal to open a menu. Findings if it is ever revived: preferred
|
||||||
|
position ~250 kept Wi-Fi while hiding apps, and `debug.spacerPos` overrode it.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- `AppListModel` is `@MainActor`; the `NSWorkspace` observers hop back with
|
- `AppListModel` is `@MainActor`; the `NSWorkspace` observers hop back with
|
||||||
@@ -102,7 +156,7 @@ installed through the tap.
|
|||||||
- 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.
|
||||||
- The menu bar tray needs Accessibility permission; do not add Screen Recording
|
- The menu bar tray needs Accessibility permission; do not add Screen Recording
|
||||||
or Apple Events usage. `MenuBarHider` must always be restored on quit, but
|
or Apple Events usage. `MenuBarHider` is restored on quit, and because the
|
||||||
because a status item dies with its process, a crash cleans up on its own.
|
cover windows belong to the process, a crash removes them automatically.
|
||||||
- `.gitignore` covers `.build/`, `SimpleTopBar.app/`, `dist/` — build artifacts
|
- `.gitignore` covers `.build/`, `SimpleTopBar.app/`, `dist/` — build artifacts
|
||||||
should never be committed.
|
should never be committed.
|
||||||
|
|||||||
@@ -23,15 +23,24 @@ permission (System Settings → Privacy & Security → Accessibility). The panel
|
|||||||
prompts for it; without it the Menu Bar section is empty and everything else
|
prompts for it; without it the Menu Bar section is empty and everything else
|
||||||
still works.
|
still works.
|
||||||
|
|
||||||
## Hiding menu bar items (best-effort)
|
## Hiding menu bar items
|
||||||
|
|
||||||
macOS has no public API to move another app's status item. The **Hide items from
|
macOS has no public API to move another app's status item. Instead of moving
|
||||||
the menu bar** toggle uses the spacer technique: a zero-width status item is
|
them, the **Hide items from the menu bar** toggle (on by default, remembered)
|
||||||
created at launch whose length is expanded to push the items to its left
|
draws a frosted **cover** window over the other apps' icons. They keep their real
|
||||||
off-screen. This is reliable only when SimpleTopBar starts at login (its spacer
|
positions, so tapping a tile in the panel opens that app's menu in the right
|
||||||
then sits to the right of later-launched apps' items) and may need re-toggling
|
place with **no flicker**, and the cover leaves a gap for the hamburger so it is
|
||||||
after items move around. It is off by default and always undone on quit; if the
|
never hidden.
|
||||||
app dies, macOS removes the spacer automatically.
|
|
||||||
|
What it does and does not cover:
|
||||||
|
|
||||||
|
- Covers **other third-party apps' icons** (cmux, Kapture, WireGuard, input
|
||||||
|
source, Now Playing, …).
|
||||||
|
- Keeps **Wi-Fi, Spotlight, Clock, Battery and Control Center** visible — macOS
|
||||||
|
reserves that far-right area, so those icons stay put.
|
||||||
|
- The cover matches the menu bar's frosted look only approximately; over an
|
||||||
|
unusual wallpaper the covered strip may be slightly visible.
|
||||||
|
- Turning the toggle off removes the cover immediately; quitting does too.
|
||||||
|
|
||||||
## Install with Homebrew
|
## Install with Homebrew
|
||||||
|
|
||||||
|
|||||||
+103
-11
@@ -7,24 +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: "line.3.horizontal") {
|
// The app is a menu bar extra, so it has no real windows.
|
||||||
TopBarView(
|
Settings { EmptyView() }
|
||||||
model: AppListModel.shared,
|
|
||||||
menuBar: MenuBarItemManager.shared,
|
|
||||||
hider: MenuBarHider.shared
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.menuBarExtraStyle(.window)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
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
|
||||||
@@ -36,11 +43,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
AppListModel.shared.start()
|
AppListModel.shared.start()
|
||||||
MenuBarItemManager.shared.start()
|
MenuBarItemManager.shared.start()
|
||||||
MenuBarHider.shared.prepare()
|
// Create the hamburger before the cover so its own icon is never covered.
|
||||||
|
setupStatusItem()
|
||||||
|
MenuBarItemManager.shared.refresh()
|
||||||
|
|
||||||
|
if CommandLine.arguments.contains("--diag-press") {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||||
|
if let item = MenuBarItemManager.enumerate().first(where: {
|
||||||
|
$0.title.lowercased().contains("wireguard")
|
||||||
|
}) {
|
||||||
|
NSLog("diag-press: pressing \(item.appName)")
|
||||||
|
_ = AXUIElementPerformAction(item.element, kAXPressAction as CFString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
|
||||||
|
dumpWindows()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if CommandLine.arguments.contains("--diag-cover") {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
||||||
|
dumpWindows()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// The tray is empty without Accessibility permission, so surface the
|
||||||
// system prompt once at launch instead of waiting for the user to
|
// system prompt once at launch instead of waiting for the user to open
|
||||||
// open the panel and find the button.
|
// the panel and find the button.
|
||||||
if !AccessibilityPermission.isGranted,
|
if !AccessibilityPermission.isGranted,
|
||||||
!UserDefaults.standard.bool(forKey: "didRequestAccessibility") {
|
!UserDefaults.standard.bool(forKey: "didRequestAccessibility") {
|
||||||
UserDefaults.standard.set(true, forKey: "didRequestAccessibility")
|
UserDefaults.standard.set(true, forKey: "didRequestAccessibility")
|
||||||
@@ -52,6 +98,52 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
// Never leave the menu bar resized behind us.
|
// Never leave the menu bar resized behind us.
|
||||||
MenuBarHider.shared.restore()
|
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 {
|
enum LoginItem {
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ struct MenuBarItem: Identifiable, Equatable {
|
|||||||
let pid: pid_t
|
let pid: pid_t
|
||||||
let appName: String
|
let appName: String
|
||||||
let title: String
|
let title: String
|
||||||
let appIcon: NSImage
|
let icon: NSImage
|
||||||
|
/// Frame in Accessibility coordinates (origin top-left of the main screen).
|
||||||
|
let frame: CGRect?
|
||||||
let element: AXUIElement
|
let element: AXUIElement
|
||||||
|
|
||||||
static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool {
|
static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool {
|
||||||
@@ -52,33 +54,61 @@ final class MenuBarItemManager: ObservableObject {
|
|||||||
authorized = AXIsProcessTrusted()
|
authorized = AXIsProcessTrusted()
|
||||||
guard authorized else {
|
guard authorized else {
|
||||||
items = []
|
items = []
|
||||||
|
MenuBarHider.shared.apply(items: [], avoid: nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items = Self.enumerate()
|
let ownPID = ProcessInfo.processInfo.processIdentifier
|
||||||
|
let all = Self.enumerate(includeOwn: true)
|
||||||
|
items = all.filter { $0.pid != ownPID }
|
||||||
|
// Our own hamburger must never be covered, even if it lands inside a run.
|
||||||
|
let avoid = all.first { $0.pid == ownPID }?.frame
|
||||||
|
MenuBarHider.shared.apply(items: items, avoid: avoid)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func enumerate() -> [MenuBarItem] {
|
nonisolated static func enumerate(includeOwn: Bool = false) -> [MenuBarItem] {
|
||||||
let ownPID = ProcessInfo.processInfo.processIdentifier
|
let ownPID = ProcessInfo.processInfo.processIdentifier
|
||||||
var found: [MenuBarItem] = []
|
var found: [MenuBarItem] = []
|
||||||
|
|
||||||
for app in NSWorkspace.shared.runningApplications {
|
for app in NSWorkspace.shared.runningApplications {
|
||||||
let pid = app.processIdentifier
|
let pid = app.processIdentifier
|
||||||
if pid == ownPID { continue }
|
if !includeOwn && pid == ownPID { continue }
|
||||||
let axApp = AXUIElementCreateApplication(pid)
|
let axApp = AXUIElementCreateApplication(pid)
|
||||||
guard let bar = extrasMenuBar(of: axApp),
|
guard let bar = extrasMenuBar(of: axApp),
|
||||||
let children = children(of: bar) else { continue }
|
let children = children(of: bar) else { continue }
|
||||||
|
|
||||||
|
var seenTitles = Set<String>()
|
||||||
for (index, child) in children.enumerated() {
|
for (index, child) in children.enumerated() {
|
||||||
|
let position = pointAttribute(child, kAXPositionAttribute)
|
||||||
|
// 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, position.y > 60 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
let title = stringAttribute(child, kAXDescriptionAttribute)
|
let title = stringAttribute(child, kAXDescriptionAttribute)
|
||||||
?? stringAttribute(child, kAXTitleAttribute)
|
?? stringAttribute(child, kAXTitleAttribute)
|
||||||
?? app.localizedName
|
?? app.localizedName
|
||||||
?? "Item"
|
?? "Item"
|
||||||
|
// Several Control Center modules share the generic title
|
||||||
|
// "Control Center"; show each distinct title once per app.
|
||||||
|
if !seenTitles.insert(title).inserted { continue }
|
||||||
|
|
||||||
|
let size = sizeAttribute(child, kAXSizeAttribute)
|
||||||
|
let frame: CGRect?
|
||||||
|
if let position, let size {
|
||||||
|
frame = CGRect(origin: position, size: size)
|
||||||
|
} else {
|
||||||
|
frame = nil
|
||||||
|
}
|
||||||
|
|
||||||
found.append(MenuBarItem(
|
found.append(MenuBarItem(
|
||||||
id: "\(pid)-\(index)",
|
id: "\(pid)-\(index)",
|
||||||
pid: pid,
|
pid: pid,
|
||||||
appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
||||||
title: title,
|
title: title,
|
||||||
appIcon: app.icon ?? NSImage(named: NSImage.applicationIconName) ?? NSImage(),
|
icon: Self.icon(forTitle: title, app: app),
|
||||||
|
frame: frame,
|
||||||
element: child
|
element: child
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -91,16 +121,66 @@ final class MenuBarItemManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the real menu bar item by pressing its accessibility element, so
|
/// Opens the real menu bar item, so its menu/popover appears at its own
|
||||||
/// its menu/popover appears at its true screen location.
|
/// on-screen position. With the cover approach the item never moves, so no
|
||||||
@discardableResult
|
/// reveal is needed.
|
||||||
func press(_ item: MenuBarItem) -> Bool {
|
func press(_ item: MenuBarItem) {
|
||||||
|
_ = performPress(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performPress(_ item: MenuBarItem) -> Bool {
|
||||||
if AXUIElementPerformAction(item.element, kAXPressAction as CFString) == .success {
|
if AXUIElementPerformAction(item.element, kAXPressAction as CFString) == .success {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return AXUIElementPerformAction(item.element, kAXShowMenuAction as CFString) == .success
|
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
|
// MARK: - AX helpers
|
||||||
|
|
||||||
nonisolated static func extrasMenuBar(of app: AXUIElement) -> AXUIElement? {
|
nonisolated static func extrasMenuBar(of app: AXUIElement) -> AXUIElement? {
|
||||||
@@ -126,51 +206,64 @@ final class MenuBarItemManager: ObservableObject {
|
|||||||
!string.isEmpty else { return nil }
|
!string.isEmpty else { return nil }
|
||||||
return string
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func sizeAttribute(_ element: AXUIElement, _ attribute: String) -> CGSize? {
|
||||||
|
var value: CFTypeRef?
|
||||||
|
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success,
|
||||||
|
let value,
|
||||||
|
CFGetTypeID(value) == AXValueGetTypeID() else { return nil }
|
||||||
|
var size = CGSize.zero
|
||||||
|
guard AXValueGetValue(value as! AXValue, .cgSize, &size) else { return nil }
|
||||||
|
return size
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort menu bar item hiding. A zero-width spacer status item is created
|
/// Menu bar item hiding via a "cover": the icons stay exactly where macOS put
|
||||||
/// at launch; expanding its length to a huge value pushes the items to its left
|
/// them and are hidden by our own frosted window drawn over them. Because they
|
||||||
/// off the visible menu bar, so they can only be reached through SimpleTopBar's
|
/// never move, opening one's menu needs no reveal. On by default; the choice is
|
||||||
/// panel. Reliable only when SimpleTopBar starts at login (its spacer then sits
|
/// persisted. Always restored on quit.
|
||||||
/// to the right of later-launched apps' items). Always restored on quit.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MenuBarHider: ObservableObject {
|
final class MenuBarHider: ObservableObject {
|
||||||
static let shared = MenuBarHider()
|
static let shared = MenuBarHider()
|
||||||
|
|
||||||
/// Width that pushes the neighbouring items off the visible menu bar.
|
private static let defaultsKey = "hideMenuBarItems"
|
||||||
static let hiddenLength: CGFloat = 10_000
|
|
||||||
|
|
||||||
@Published private(set) var isHiding = false
|
@Published private(set) var isHiding = false
|
||||||
@Published var enabled = false {
|
@Published var enabled: Bool {
|
||||||
didSet {
|
didSet {
|
||||||
guard enabled != oldValue else { return }
|
guard enabled != oldValue else { return }
|
||||||
apply(enabled)
|
UserDefaults.standard.set(enabled, forKey: Self.defaultsKey)
|
||||||
|
MenuBarItemManager.shared.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var spacer: NSStatusItem?
|
private init() {
|
||||||
|
// Hide by default: the menu bar then shows only SimpleTopBar's hamburger
|
||||||
private init() {}
|
// plus the Apple items macOS will not let us cover.
|
||||||
|
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 item = NSStatusBar.system.statusItem(withLength: 0)
|
|
||||||
item.button?.isHidden = true
|
|
||||||
spacer = item
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func apply(_ hiding: Bool) {
|
/// Recompute the cover windows for the current menu bar items. `avoid` is
|
||||||
prepare()
|
/// our own hamburger's frame, which the cover must never hide.
|
||||||
spacer?.length = hiding ? Self.hiddenLength : 0
|
func apply(items: [MenuBarItem], avoid: CGRect?) {
|
||||||
isHiding = hiding
|
isHiding = enabled
|
||||||
|
MenuBarCover.shared.update(enabled: enabled, items: items, avoid: avoid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Put the menu bar back the way we found it. Called on quit.
|
/// Remove the cover. Called on quit. Leaves the user's preference intact.
|
||||||
func restore() {
|
func restore() {
|
||||||
spacer?.length = 0
|
|
||||||
isHiding = false
|
isHiding = false
|
||||||
enabled = false
|
MenuBarCover.shared.update(enabled: false, items: [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Hides other apps' menu bar icons by drawing a frosted window ("cover") over
|
||||||
|
/// them, instead of moving them off-screen. The icons keep their real positions,
|
||||||
|
/// so their menus open in the right place with no reveal, and a target item's
|
||||||
|
/// real icon can be left visible by cutting a hole in the cover.
|
||||||
|
@MainActor
|
||||||
|
final class MenuBarCover {
|
||||||
|
static let shared = MenuBarCover()
|
||||||
|
|
||||||
|
/// Just above the status items (level 25) but well below menus (~101).
|
||||||
|
private static let windowLevel = NSWindow.Level(rawValue: 26)
|
||||||
|
|
||||||
|
private var windows: [NSWindow] = []
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func update(enabled: Bool, items: [MenuBarItem], avoid: CGRect? = nil) {
|
||||||
|
removeAllWindows()
|
||||||
|
guard enabled else { return }
|
||||||
|
|
||||||
|
let frames = items.compactMap { item -> CGRect? in
|
||||||
|
guard Self.shouldHide(item), let frame = item.frame else { return nil }
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
var runs = Self.runs(from: frames)
|
||||||
|
if let avoid {
|
||||||
|
runs = Self.subtracting(avoid, from: runs)
|
||||||
|
}
|
||||||
|
let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
|
||||||
|
for run in runs {
|
||||||
|
let frame = Self.windowFrame(for: run, primaryHeight: primaryHeight)
|
||||||
|
windows.append(makeWindow(frame: frame))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which items the cover hides. Everything is covered except the Apple
|
||||||
|
/// items the user asked to keep visible.
|
||||||
|
nonisolated static func shouldHide(_ item: MenuBarItem) -> Bool {
|
||||||
|
let text = item.title.lowercased()
|
||||||
|
.replacingOccurrences(of: "\u{2010}", with: "-")
|
||||||
|
.replacingOccurrences(of: "\u{2011}", with: "-")
|
||||||
|
let keep = ["wi-fi", "wifi", "battery", "clock", "control center", "search", "spotlight"]
|
||||||
|
return !keep.contains(where: { text.contains($0) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge overlapping or near frames (in Accessibility coordinates) into
|
||||||
|
/// contiguous runs, so adjacent icons get one cover window rather than many.
|
||||||
|
nonisolated static func runs(from frames: [CGRect], gap: CGFloat = 6) -> [CGRect] {
|
||||||
|
let sorted = frames.sorted { $0.minX < $1.minX }
|
||||||
|
var result: [CGRect] = []
|
||||||
|
for frame in sorted {
|
||||||
|
if let last = result.last, frame.minX <= last.maxX + gap {
|
||||||
|
result[result.count - 1] = last.union(frame)
|
||||||
|
} else {
|
||||||
|
result.append(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carve `avoid` out of the runs so the cover never hides our own icon.
|
||||||
|
nonisolated static func subtracting(_ avoid: CGRect, from runs: [CGRect]) -> [CGRect] {
|
||||||
|
var result: [CGRect] = []
|
||||||
|
for run in runs {
|
||||||
|
guard run.intersects(avoid) else {
|
||||||
|
result.append(run)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if avoid.minX > run.minX {
|
||||||
|
result.append(CGRect(x: run.minX, y: run.minY, width: avoid.minX - run.minX, height: run.height))
|
||||||
|
}
|
||||||
|
if avoid.maxX < run.maxX {
|
||||||
|
result.append(CGRect(x: avoid.maxX, y: run.minY, width: run.maxX - avoid.maxX, height: run.height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accessibility coordinates use a top-left origin; NSWindow uses a
|
||||||
|
/// bottom-left one, so flip Y against the primary screen's height.
|
||||||
|
nonisolated static func windowFrame(for axFrame: CGRect, primaryHeight: CGFloat) -> CGRect {
|
||||||
|
CGRect(
|
||||||
|
x: axFrame.minX,
|
||||||
|
y: primaryHeight - axFrame.maxY,
|
||||||
|
width: axFrame.width,
|
||||||
|
height: axFrame.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeWindow(frame: CGRect) -> NSWindow {
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: frame,
|
||||||
|
styleMask: [.borderless],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.level = Self.windowLevel
|
||||||
|
window.isOpaque = false
|
||||||
|
window.backgroundColor = .clear
|
||||||
|
window.hasShadow = false
|
||||||
|
// Swallow clicks so the covered icons cannot be clicked through.
|
||||||
|
window.ignoresMouseEvents = false
|
||||||
|
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
|
||||||
|
window.animationBehavior = .none
|
||||||
|
|
||||||
|
let effect = NSVisualEffectView(frame: CGRect(origin: .zero, size: frame.size))
|
||||||
|
effect.material = .menu
|
||||||
|
effect.blendingMode = .behindWindow
|
||||||
|
effect.state = .active
|
||||||
|
effect.autoresizingMask = [.width, .height]
|
||||||
|
window.contentView = effect
|
||||||
|
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeAllWindows() {
|
||||||
|
for window in windows {
|
||||||
|
window.orderOut(nil)
|
||||||
|
}
|
||||||
|
windows.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,55 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ApplicationServices
|
import ApplicationServices
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
/// Diagnostic used by `open SimpleTopBar.app --args --diag-press`. Presses a
|
||||||
|
/// hidden item's accessibility element without revealing it and dumps the
|
||||||
|
/// resulting high-level windows, to see where its menu actually opens.
|
||||||
|
func dumpWindows() {
|
||||||
|
var lines: [String] = []
|
||||||
|
if let list = CGWindowListCopyWindowInfo([], kCGNullWindowID) as? [[String: Any]] {
|
||||||
|
for window in list {
|
||||||
|
let layer = window[kCGWindowLayer as String] as? Int ?? -1
|
||||||
|
guard layer > 20 else { continue }
|
||||||
|
let owner = window[kCGWindowOwnerName as String] as? String ?? "?"
|
||||||
|
let bounds = window[kCGWindowBounds as String] as? [String: Any] ?? [:]
|
||||||
|
let x = bounds["X"] as? Double ?? 0
|
||||||
|
let y = bounds["Y"] as? Double ?? 0
|
||||||
|
let width = bounds["Width"] as? Double ?? 0
|
||||||
|
let height = bounds["Height"] as? Double ?? 0
|
||||||
|
lines.append("\(owner) layer=\(layer) frame=\(Int(x)),\(Int(y)) \(Int(width))x\(Int(height))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? lines.joined(separator: "\n").write(toFile: "/tmp/simpletopbar-windows.txt", atomically: true, encoding: .utf8)
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
@@ -53,12 +103,41 @@ func runSelfTest() {
|
|||||||
check(MenuBarItemManager.children(of: systemWide) == nil, "no children on a non-app element")
|
check(MenuBarItemManager.children(of: systemWide) == nil, "no children on a non-app element")
|
||||||
|
|
||||||
let element = AXUIElementCreateSystemWide()
|
let element = AXUIElementCreateSystemWide()
|
||||||
let first = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", appIcon: NSImage(), element: element)
|
let first = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", icon: NSImage(), frame: nil, element: element)
|
||||||
let same = 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", icon: NSImage(), frame: nil, element: element)
|
||||||
let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", appIcon: NSImage(), element: element)
|
let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", icon: NSImage(), frame: nil, element: element)
|
||||||
check(first == same, "menu bar items compare by id and title")
|
check(first == same, "menu bar items compare by id and title")
|
||||||
check(first != different, "different ids are not equal")
|
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")
|
||||||
|
|
||||||
|
// Cover geometry: adjacent icons merge into one run, far ones stay separate.
|
||||||
|
let merged = MenuBarCover.runs(from: [CGRect(x: 0, y: 0, width: 10, height: 20),
|
||||||
|
CGRect(x: 12, y: 0, width: 10, height: 20)])
|
||||||
|
check(merged == [CGRect(x: 0, y: 0, width: 22, height: 20)], "adjacent frames merge into one cover")
|
||||||
|
let split = MenuBarCover.runs(from: [CGRect(x: 0, y: 0, width: 10, height: 20),
|
||||||
|
CGRect(x: 100, y: 0, width: 10, height: 20)])
|
||||||
|
check(split.count == 2, "distant frames stay separate covers")
|
||||||
|
|
||||||
|
// Cover hides third-party apps but keeps the Apple items.
|
||||||
|
let hiddenApp = MenuBarItem(id: "1-0", pid: 1, appName: "cmux", title: "cmux", icon: NSImage(), frame: nil, element: element)
|
||||||
|
let keptWiFi = MenuBarItem(id: "2-0", pid: 2, appName: "Control Center", title: "Wi\u{2011}Fi, connected", icon: NSImage(), frame: nil, element: element)
|
||||||
|
let keptBattery = MenuBarItem(id: "2-1", pid: 2, appName: "Control Center", title: "Battery", icon: NSImage(), frame: nil, element: element)
|
||||||
|
let hiddenPlaying = MenuBarItem(id: "2-2", pid: 2, appName: "Control Center", title: "Now Playing", icon: NSImage(), frame: nil, element: element)
|
||||||
|
check(MenuBarCover.shouldHide(hiddenApp), "third-party apps are covered")
|
||||||
|
check(!MenuBarCover.shouldHide(keptWiFi), "wi-fi stays visible")
|
||||||
|
check(!MenuBarCover.shouldHide(keptBattery), "battery stays visible")
|
||||||
|
check(MenuBarCover.shouldHide(hiddenPlaying), "now playing is covered")
|
||||||
|
|
||||||
|
let converted = MenuBarCover.windowFrame(for: CGRect(x: 10, y: 0, width: 30, height: 24), primaryHeight: 1000)
|
||||||
|
check(converted == CGRect(x: 10, y: 976, width: 30, height: 24), "AX frame flips Y for NSWindow")
|
||||||
|
|
||||||
let authorized = AXIsProcessTrusted()
|
let authorized = AXIsProcessTrusted()
|
||||||
print("accessibility authorized=\(authorized)")
|
print("accessibility authorized=\(authorized)")
|
||||||
if authorized {
|
if authorized {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ struct TopBarView: View {
|
|||||||
.controlSize(.mini)
|
.controlSize(.mini)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.top, 6)
|
.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.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,8 +245,11 @@ private struct MenuBarTile: View {
|
|||||||
Button {
|
Button {
|
||||||
menuBar.press(item)
|
menuBar.press(item)
|
||||||
} label: {
|
} label: {
|
||||||
Image(nsImage: item.appIcon)
|
// 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()
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20, height: 20)
|
||||||
.padding(6)
|
.padding(6)
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
VERSION="${1:-0.2.1}"
|
VERSION="${1:-0.2.3}"
|
||||||
|
|
||||||
swift build -c release
|
swift build -c release
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user