Add SimpleTopBar menu bar app with collapsible background app list
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import ServiceManagement
|
||||
|
||||
@main
|
||||
struct SimpleTopBarApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra("SimpleTopBar", systemImage: "menubar.rectangle") {
|
||||
TopBarView(model: AppListModel.shared)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
}
|
||||
}
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
if CommandLine.arguments.contains("--self-test") {
|
||||
runSelfTest()
|
||||
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
|
||||
let others = NSRunningApplication.runningApplications(withBundleIdentifier: "dev.tzgyn.simpletopbar")
|
||||
.filter { $0.processIdentifier != myPid }
|
||||
for other in others {
|
||||
other.forceTerminate()
|
||||
}
|
||||
|
||||
AppListModel.shared.start()
|
||||
}
|
||||
}
|
||||
|
||||
enum LoginItem {
|
||||
static var isEnabled: Bool {
|
||||
SMAppService.mainApp.status == .enabled
|
||||
}
|
||||
|
||||
static func toggle() -> Bool {
|
||||
let service = SMAppService.mainApp
|
||||
do {
|
||||
if service.status == .enabled {
|
||||
try service.unregister()
|
||||
} else {
|
||||
try service.register()
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
|
||||
/// A snapshot of one running application shown in the top bar.
|
||||
struct RunningApp: Identifiable, Equatable {
|
||||
let id: pid_t
|
||||
let name: String
|
||||
let bundleID: String?
|
||||
let icon: NSImage
|
||||
let isHidden: Bool
|
||||
let isActive: Bool
|
||||
|
||||
static func == (lhs: RunningApp, rhs: RunningApp) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
&& lhs.name == rhs.name
|
||||
&& lhs.isHidden == rhs.isHidden
|
||||
&& lhs.isActive == rhs.isActive
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks running applications and splits them into the two top bar sections:
|
||||
/// regular apps (with a Dock icon) and background apps (accessory/agent apps).
|
||||
@MainActor
|
||||
final class AppListModel: ObservableObject {
|
||||
static let shared = AppListModel()
|
||||
|
||||
/// Number of background apps shown before the rest are hidden behind the
|
||||
/// "Show more" affordance.
|
||||
nonisolated static let backgroundPageSize = 5
|
||||
|
||||
@Published private(set) var regularApps: [RunningApp] = []
|
||||
@Published private(set) var backgroundApps: [RunningApp] = []
|
||||
|
||||
/// Whether the background-app section is expanded. Collapsed means the
|
||||
/// section renders as a single dropdown header.
|
||||
@Published var backgroundExpanded = false
|
||||
/// How many background apps are currently revealed after expanding.
|
||||
@Published private(set) var backgroundVisibleCount = AppListModel.backgroundPageSize
|
||||
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
private init() {}
|
||||
|
||||
func start() {
|
||||
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() {
|
||||
let ownBundleID = Bundle.main.bundleIdentifier
|
||||
var regular: [RunningApp] = []
|
||||
var background: [RunningApp] = []
|
||||
|
||||
for app in NSWorkspace.shared.runningApplications {
|
||||
// Never list ourselves.
|
||||
if let ownBundleID, app.bundleIdentifier == ownBundleID { continue }
|
||||
|
||||
let info = RunningApp(
|
||||
id: app.processIdentifier,
|
||||
name: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
||||
bundleID: app.bundleIdentifier,
|
||||
icon: app.icon ?? NSImage(named: NSImage.applicationIconName) ?? NSImage(),
|
||||
isHidden: app.isHidden,
|
||||
isActive: app.isActive
|
||||
)
|
||||
|
||||
if app.activationPolicy == .regular {
|
||||
regular.append(info)
|
||||
} else {
|
||||
background.append(info)
|
||||
}
|
||||
}
|
||||
|
||||
regularApps = Self.sorted(regular)
|
||||
backgroundApps = Self.sorted(background)
|
||||
|
||||
// Keep the revealed count valid as apps come and go.
|
||||
backgroundVisibleCount = Self.clampedVisibleCount(
|
||||
current: backgroundVisibleCount,
|
||||
total: backgroundApps.count
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated static func sorted(_ apps: [RunningApp]) -> [RunningApp] {
|
||||
apps.sorted { a, b in
|
||||
let result = a.name.localizedCaseInsensitiveCompare(b.name)
|
||||
if result == .orderedSame { return a.id < b.id }
|
||||
return result == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
/// Background apps that the expanded section should render right now.
|
||||
var visibleBackgroundApps: [RunningApp] {
|
||||
guard backgroundExpanded else { return [] }
|
||||
return Array(backgroundApps.prefix(backgroundVisibleCount))
|
||||
}
|
||||
|
||||
/// How many background apps are currently hidden behind "Show more".
|
||||
var hiddenBackgroundCount: Int {
|
||||
max(0, backgroundApps.count - backgroundVisibleCount)
|
||||
}
|
||||
|
||||
var canShowMoreBackgroundApps: Bool { hiddenBackgroundCount > 0 }
|
||||
|
||||
func toggleBackgroundExpanded() {
|
||||
backgroundExpanded.toggle()
|
||||
if !backgroundExpanded {
|
||||
backgroundVisibleCount = Self.backgroundPageSize
|
||||
}
|
||||
}
|
||||
|
||||
func showMoreBackgroundApps() {
|
||||
backgroundVisibleCount = Self.nextVisibleCount(
|
||||
current: backgroundVisibleCount,
|
||||
total: backgroundApps.count
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Pure helpers (exercised by --self-test)
|
||||
|
||||
nonisolated static func clampedVisibleCount(current: Int, total: Int) -> Int {
|
||||
max(0, min(current, total))
|
||||
}
|
||||
|
||||
nonisolated static func nextVisibleCount(current: Int, total: Int) -> Int {
|
||||
min(total, current + backgroundPageSize)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func activate(_ app: RunningApp) -> Bool {
|
||||
guard let running = NSRunningApplication(processIdentifier: app.id) else { return false }
|
||||
return running.activate(options: [.activateIgnoringOtherApps])
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleHidden(_ app: RunningApp) -> Bool {
|
||||
guard let running = NSRunningApplication(processIdentifier: app.id) else { return false }
|
||||
if running.isHidden {
|
||||
return running.unhide()
|
||||
}
|
||||
return running.hide()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func quit(_ app: RunningApp) -> Bool {
|
||||
guard let running = NSRunningApplication(processIdentifier: app.id) else { return false }
|
||||
return running.terminate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import AppKit
|
||||
|
||||
/// 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.
|
||||
func runSelfTest() {
|
||||
var failures = 0
|
||||
|
||||
func check(_ condition: Bool, _ label: String) {
|
||||
print((condition ? "PASS" : "FAIL") + ": " + label)
|
||||
if !condition { failures += 1 }
|
||||
}
|
||||
|
||||
// clampedVisibleCount never exceeds the total and never goes negative.
|
||||
check(AppListModel.clampedVisibleCount(current: 10, total: 3) == 3, "clamp caps at total")
|
||||
check(AppListModel.clampedVisibleCount(current: -2, total: 5) == 0, "clamp floors at zero")
|
||||
check(AppListModel.clampedVisibleCount(current: 2, total: 5) == 2, "clamp keeps valid value")
|
||||
|
||||
// nextVisibleCount pages by backgroundPageSize and stops at the total.
|
||||
let page = AppListModel.backgroundPageSize
|
||||
check(AppListModel.nextVisibleCount(current: page, total: 100) == page * 2, "show more pages forward")
|
||||
check(AppListModel.nextVisibleCount(current: 4, total: 5) == 5, "show more stops at total")
|
||||
check(AppListModel.nextVisibleCount(current: 5, total: 5) == 5, "show more is a no-op at the end")
|
||||
|
||||
// The section reveals exactly `min(visibleCount, total)` entries.
|
||||
let apps = (0..<12).map { index in
|
||||
RunningApp(
|
||||
id: pid_t(index + 1),
|
||||
name: "App \(index)",
|
||||
bundleID: nil,
|
||||
icon: NSImage(),
|
||||
isHidden: false,
|
||||
isActive: false
|
||||
)
|
||||
}
|
||||
let hidden = max(0, apps.count - 3)
|
||||
check(hidden == 9, "overflow count hides the tail")
|
||||
|
||||
// Live snapshot: the split must cover every running app exactly once.
|
||||
let running = NSWorkspace.shared.runningApplications.filter {
|
||||
$0.bundleIdentifier != Bundle.main.bundleIdentifier
|
||||
}
|
||||
let regular = running.filter { $0.activationPolicy == .regular }.count
|
||||
let background = running.filter { $0.activationPolicy != .regular }.count
|
||||
print("running=\(running.count) regular=\(regular) background=\(background)")
|
||||
check(regular + background == running.count, "every running app is classified")
|
||||
|
||||
print(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")
|
||||
exit(failures == 0 ? 0 : 1)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// The top bar panel shown from the menu bar icon.
|
||||
struct TopBarView: View {
|
||||
@ObservedObject var model: AppListModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
section(title: "Apps", apps: model.regularApps, emptyText: "No apps running")
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
backgroundSection
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.frame(maxHeight: 360)
|
||||
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(width: 320)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "menubar.rectangle")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("SimpleTopBar")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
model.refresh()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Refresh")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func section(title: String, apps: [RunningApp], emptyText: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title.uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 2)
|
||||
|
||||
if apps.isEmpty {
|
||||
Text(emptyText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ForEach(apps) { app in
|
||||
AppRow(app: app, model: model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapsible dropdown for background apps. When collapsed it is a single
|
||||
/// header row; when expanded it reveals a limited number of apps and hides
|
||||
/// the overflow behind "Show N more".
|
||||
private var backgroundSection: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button {
|
||||
model.toggleBackgroundExpanded()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: model.backgroundExpanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 10)
|
||||
Text("Background Apps")
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(model.backgroundApps.count)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(Capsule().fill(Color.secondary.opacity(0.15)))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.help(model.backgroundExpanded ? "Collapse background apps" : "Expand background apps")
|
||||
|
||||
if model.backgroundExpanded {
|
||||
if model.backgroundApps.isEmpty {
|
||||
Text("No background apps")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ForEach(model.visibleBackgroundApps) { app in
|
||||
AppRow(app: app, model: model)
|
||||
}
|
||||
|
||||
if model.canShowMoreBackgroundApps {
|
||||
Button {
|
||||
model.showMoreBackgroundApps()
|
||||
} label: {
|
||||
Label(
|
||||
"Show \(model.hiddenBackgroundCount) more",
|
||||
systemImage: "ellipsis.circle"
|
||||
)
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("\(model.regularApps.count) apps · \(model.backgroundApps.count) background")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
Spacer()
|
||||
Button(LoginItem.isEnabled ? "Disable Launch at Login" : "Launch at Login") {
|
||||
if !LoginItem.toggle() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Could not update Launch at Login"
|
||||
alert.informativeText = "Launch at Login requires a signed app bundle in /Applications. It is not available when running via `swift run`."
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button("Quit") { NSApp.terminate(nil) }
|
||||
.font(.caption2)
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut("q")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppRow: View {
|
||||
let app: RunningApp
|
||||
@ObservedObject var model: AppListModel
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
model.activate(app)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(nsImage: app.icon)
|
||||
.resizable()
|
||||
.frame(width: 18, height: 18)
|
||||
Text(app.name)
|
||||
.font(.system(size: 12))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
if app.isActive {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.system(size: 5))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 3)
|
||||
.opacity(app.isHidden ? 0.55 : 1)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Activate \(app.name)")
|
||||
.contextMenu {
|
||||
Button("Activate") { model.activate(app) }
|
||||
Button(app.isHidden ? "Unhide" : "Hide") { model.toggleHidden(app) }
|
||||
Divider()
|
||||
Button("Quit \(app.name)") { model.quit(app) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user