Add SimpleTopBar menu bar app with collapsible background app list
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# SwiftPM
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
|
||||||
|
# App bundle (build artifact, regenerate with ./build-app.sh)
|
||||||
|
SimpleTopBar.app/
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
*.p8
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Xcode
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
dist/
|
||||||
|
notary-result.json
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
SimpleTopBar is a native macOS menu-bar app written in Swift/SwiftUI. Single
|
||||||
|
executable SwiftPM package, no external dependencies (all Apple frameworks).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `Sources/SimpleTopBar/App.swift` — `@main` entry. `MenuBarExtra` with
|
||||||
|
`.menuBarExtraStyle(.window)` hosting `TopBarView`. `AppDelegate` handles the
|
||||||
|
`--self-test` flag and the single-instance guard. `LoginItem` wraps
|
||||||
|
`SMAppService`.
|
||||||
|
- `Sources/SimpleTopBar/AppListModel.swift` — `@MainActor` `ObservableObject`
|
||||||
|
singleton. `refresh()` reads `NSWorkspace.shared.runningApplications` and
|
||||||
|
splits them by `activationPolicy` into `regularApps` and `backgroundApps`
|
||||||
|
(both sorted by name). Subscribes to the `NSWorkspace` launch/terminate/
|
||||||
|
activate/hide/unhide notifications to refresh automatically.
|
||||||
|
- `Sources/SimpleTopBar/TopBarView.swift` — the panel. `section(...)` renders
|
||||||
|
the regular apps; `backgroundSection` is the collapsible dropdown for
|
||||||
|
background apps. `AppRow` shows icon + name + active dot with a right-click
|
||||||
|
Activate/Hide/Quit menu.
|
||||||
|
- `Sources/SimpleTopBar/SelfTest.swift` — `runSelfTest()` powers `--self-test`.
|
||||||
|
|
||||||
|
## The background dropdown
|
||||||
|
|
||||||
|
This is the core feature. Requirements:
|
||||||
|
|
||||||
|
- The background-app section is a **dropdown**, not a flat list. Collapsed, it
|
||||||
|
is one header row showing the count.
|
||||||
|
- Expanded, it shows at most `AppListModel.backgroundPageSize` (5) apps. Any
|
||||||
|
overflow is **hidden** behind a "Show N more" row, so the panel never grows
|
||||||
|
unbounded. Collapsing resets the page.
|
||||||
|
- `AppListModel` drives all of this: `backgroundExpanded`,
|
||||||
|
`backgroundVisibleCount`, `visibleBackgroundApps`, `hiddenBackgroundCount`,
|
||||||
|
`canShowMoreBackgroundApps`, `toggleBackgroundExpanded()`,
|
||||||
|
`showMoreBackgroundApps()`.
|
||||||
|
- The pure helpers `clampedVisibleCount` and `nextVisibleCount` are the
|
||||||
|
overflow math and are covered by `--self-test`. Keep them pure and
|
||||||
|
`nonisolated` (no AppKit, no actor state).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swift build # debug build
|
||||||
|
swift run SimpleTopBar # dev run (menu bar icon appears)
|
||||||
|
swift run SimpleTopBar --self-test # headless overflow + classification check, exit 0/1
|
||||||
|
./build-app.sh 0.1.0 # release build + .app bundle + icon + codesign
|
||||||
|
./release.sh 0.1.0 # build-app.sh + dist/SimpleTopBar.zip + sha256
|
||||||
|
scripts/make-icon.sh # regenerate SimpleTopBar.app/Contents/Resources/Icon.icns
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no test target and no linter/formatter configured. Verification = build
|
||||||
|
+ `--self-test`.
|
||||||
|
|
||||||
|
## Bundle build & signing
|
||||||
|
|
||||||
|
`build-app.sh` does: release build → handwritten `Info.plist` (NOT generated
|
||||||
|
from `Package.swift`) → `make-icon.sh` → `codesign`. It resolves a
|
||||||
|
`Developer ID Application` identity from the login keychain and falls back to
|
||||||
|
ad-hoc signing (`-`) if none is present. `LSUIElement` is true → no Dock icon.
|
||||||
|
Bump `CFBundleShortVersionString`/`CFBundleVersion` via the `build-app.sh`
|
||||||
|
version argument and pass the same version to `release.sh`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- `AppListModel` is `@MainActor`; the `NSWorkspace` observers hop back with
|
||||||
|
`Task { @MainActor in ... }` because the notification closure is nonisolated.
|
||||||
|
- The app excludes itself from the list via `Bundle.main.bundleIdentifier`, and
|
||||||
|
the single-instance guard terminates stale copies with the same bundle id.
|
||||||
|
- Reading `activationPolicy` is reliable but must happen on the main thread;
|
||||||
|
keep all `NSWorkspace` access inside the model.
|
||||||
|
- Don't add Accessibility/Screen Recording permission code: this app only reads
|
||||||
|
the running-app list, which needs no TCC grants.
|
||||||
|
- `.gitignore` covers `.build/`, `SimpleTopBar.app/`, `dist/` — build artifacts
|
||||||
|
should never be committed.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "SimpleTopBar",
|
||||||
|
platforms: [.macOS(.v13)],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "SimpleTopBar",
|
||||||
|
path: "Sources/SimpleTopBar"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# SimpleTopBar
|
||||||
|
|
||||||
|
A native macOS menu bar app that shows a compact top bar with every app that is
|
||||||
|
running on your Mac, split into two sections:
|
||||||
|
|
||||||
|
- **Apps** — regular apps that live in the Dock.
|
||||||
|
- **Background Apps** — accessory/agent apps without a Dock icon. This section
|
||||||
|
is a **dropdown**: it stays collapsed to a single header, and when you expand
|
||||||
|
it only the first few apps are shown. The rest are hidden behind a
|
||||||
|
**Show N more** row so the list never overflows the panel.
|
||||||
|
|
||||||
|
Click any row to activate the app, or right-click for Activate / Hide / Quit.
|
||||||
|
|
||||||
|
## Install with Homebrew
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap TZGyn/tap https://gitea.tzgyn.dev/TZGyn/homebrew-tap.git
|
||||||
|
brew trust TZGyn/tap
|
||||||
|
brew install --cask simpletopbar
|
||||||
|
```
|
||||||
|
|
||||||
|
The tap is hosted on Gitea, so the full URL is required. Homebrew may print an
|
||||||
|
"Unsupported tap" warning — it's harmless.
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swift build # debug build
|
||||||
|
swift run SimpleTopBar # run from terminal (app appears in the menu bar)
|
||||||
|
swift run SimpleTopBar --self-test # headless check of the dropdown logic
|
||||||
|
./build-app.sh 0.1.0 # release build + SimpleTopBar.app bundle + icon + codesign
|
||||||
|
./release.sh 0.1.0 # build-app.sh + dist/SimpleTopBar.zip + sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
## Around the Background dropdown
|
||||||
|
|
||||||
|
The background section is collapsed by default. Expanding it reveals
|
||||||
|
`AppListModel.backgroundPageSize` (5) apps at a time; when more are running a
|
||||||
|
**Show N more** button reveals the next page. Collapsing resets the page.
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
`./release.sh <version>` builds and zips the app to `dist/SimpleTopBar.zip` and
|
||||||
|
prints its sha256. Tag `v<version>`, attach the zip to a release on the Gitea
|
||||||
|
repo, and bump `version`/`sha256` in the cask in
|
||||||
|
[TZGyn/homebrew-tap](https://gitea.tzgyn.dev/TZGyn/homebrew-tap).
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
VERSION="${1:-0.1.0}"
|
||||||
|
|
||||||
|
swift build -c release
|
||||||
|
|
||||||
|
BIN=".build/release/SimpleTopBar"
|
||||||
|
APP="SimpleTopBar.app"
|
||||||
|
IDENT=""
|
||||||
|
|
||||||
|
# Prefer a Developer ID Application identity so the app gets clean Gatekeeper
|
||||||
|
# behavior; fall back to ad-hoc signing (no TCC-stable identity) otherwise.
|
||||||
|
if command -v security >/dev/null 2>&1; then
|
||||||
|
IDENT=$(security find-identity -v -p codesigning 2>/dev/null | awk '/Developer ID Application:/ { print $2; exit }')
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$APP"
|
||||||
|
mkdir -p "$APP/Contents/MacOS"
|
||||||
|
cp "$BIN" "$APP/Contents/MacOS/SimpleTopBar"
|
||||||
|
|
||||||
|
cat > "$APP/Contents/Info.plist" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>SimpleTopBar</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>dev.tzgyn.simpletopbar</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>SimpleTopBar</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>Icon</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>${VERSION}</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>${VERSION}</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>NSApplication</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
chmod +x scripts/make-icon.sh
|
||||||
|
./scripts/make-icon.sh
|
||||||
|
|
||||||
|
if [ -n "$IDENT" ]; then
|
||||||
|
security unlock-keychain "$HOME/Library/Keychains/login.keychain-db" 2>/dev/null || true
|
||||||
|
codesign --force --options runtime --sign "$IDENT" "$APP"
|
||||||
|
echo "Built $APP ${VERSION} (signed with Developer ID '$IDENT') — open $APP"
|
||||||
|
else
|
||||||
|
codesign --force --sign - "$APP"
|
||||||
|
echo "Built $APP ${VERSION} (ad-hoc signed) — open $APP"
|
||||||
|
fi
|
||||||
Executable
+77
@@ -0,0 +1,77 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ]; then
|
||||||
|
echo "usage: $0 <version> e.g. ./release.sh 0.1.0" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VERSION="$1"
|
||||||
|
|
||||||
|
# Notarization credentials. Configure once, e.g. in ~/.zshrc:
|
||||||
|
# export SIMPLETOPBAR_NOTARY_ASC_PROVIDER="<TeamID>"
|
||||||
|
# export SIMPLETOPBAR_NOTARY_API_KEY_PATH="$HOME/.config/simpletopbar/AuthKey_XXXX.p8"
|
||||||
|
# export SIMPLETOPBAR_NOTARY_API_KEY_ID="<KeyID>"
|
||||||
|
# export SIMPLETOPBAR_NOTARY_API_ISSUER="<IssuerID>"
|
||||||
|
# If these are unset (or SIMPLETOPBAR_SKIP_NOTARY=1), release.sh builds, zips,
|
||||||
|
# and stops there — the zip is signed but NOT notarized.
|
||||||
|
|
||||||
|
./build-app.sh "$VERSION"
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
rm -f dist/SimpleTopBar.zip
|
||||||
|
ditto -c -k --keepParent SimpleTopBar.app dist/SimpleTopBar.zip
|
||||||
|
|
||||||
|
if [ -n "${SIMPLETOPBAR_SKIP_NOTARY:-}" ]; then
|
||||||
|
echo
|
||||||
|
echo "Skipping notarization (SIMPLETOPBAR_SKIP_NOTARY set)."
|
||||||
|
shasum -a 256 dist/SimpleTopBar.zip
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
REQUIRED_VARS="SIMPLETOPBAR_NOTARY_ASC_PROVIDER SIMPLETOPBAR_NOTARY_API_KEY_PATH SIMPLETOPBAR_NOTARY_API_KEY_ID SIMPLETOPBAR_NOTARY_API_ISSUER"
|
||||||
|
MISSING=""
|
||||||
|
for v in $REQUIRED_VARS; do
|
||||||
|
if [ -z "${!v:-}" ]; then
|
||||||
|
MISSING="$MISSING $v"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$MISSING" ]; then
|
||||||
|
echo "Notarization skipped — missing env var(s):$MISSING (see header comments)." >&2
|
||||||
|
shasum -a 256 dist/SimpleTopBar.zip
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v xcrun >/dev/null 2>&1 || ! xcrun --find notarytool >/dev/null 2>&1; then
|
||||||
|
echo "Notarization skipped — xcrun notarytool not found (requires Xcode 13+)." >&2
|
||||||
|
shasum -a 256 dist/SimpleTopBar.zip
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Submitting to Apple for notarization (first run can take minutes)..."
|
||||||
|
xcrun notarytool submit dist/SimpleTopBar.zip \
|
||||||
|
--key "$SIMPLETOPBAR_NOTARY_API_KEY_PATH" \
|
||||||
|
--key-id "$SIMPLETOPBAR_NOTARY_API_KEY_ID" \
|
||||||
|
--issuer "$SIMPLETOPBAR_NOTARY_API_ISSUER" \
|
||||||
|
--wait \
|
||||||
|
--output-format json > notary-result.json
|
||||||
|
|
||||||
|
STATUS=$(jq -r '.status' notary-result.json 2>/dev/null || echo "unknown")
|
||||||
|
if [ "$STATUS" != "Accepted" ]; then
|
||||||
|
echo "Notarization failed with status: $STATUS" >&2
|
||||||
|
cat notary-result.json >&2 || true
|
||||||
|
rm -f notary-result.json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f notary-result.json
|
||||||
|
|
||||||
|
echo "Notarization accepted — stapling ticket onto the app..."
|
||||||
|
xcrun stapler staple SimpleTopBar.app
|
||||||
|
xcrun stapler validate SimpleTopBar.app
|
||||||
|
|
||||||
|
rm -f dist/SimpleTopBar.zip
|
||||||
|
ditto -c -k --keepParent SimpleTopBar.app dist/SimpleTopBar.zip
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Done — notarized."
|
||||||
|
shasum -a 256 dist/SimpleTopBar.zip
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
let size = NSSize(width: 1024, height: 1024)
|
||||||
|
guard let rep = NSBitmapImageRep(
|
||||||
|
bitmapDataPlanes: nil, pixelsWide: 1024, pixelsHigh: 1024,
|
||||||
|
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||||
|
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
|
||||||
|
) else { exit(1) }
|
||||||
|
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
||||||
|
NSGraphicsContext.current?.imageInterpolation = .high
|
||||||
|
|
||||||
|
let gradient = NSGradient(colors: [
|
||||||
|
NSColor(calibratedRed: 0.24, green: 0.30, blue: 0.38, alpha: 1),
|
||||||
|
NSColor(calibratedRed: 0.10, green: 0.13, blue: 0.18, alpha: 1),
|
||||||
|
])!
|
||||||
|
gradient.draw(in: NSRect(origin: .zero, size: size), angle: -90)
|
||||||
|
|
||||||
|
// The top bar itself.
|
||||||
|
let bar = NSBezierPath(roundedRect: NSRect(x: 128, y: 560, width: 768, height: 220), xRadius: 48, yRadius: 48)
|
||||||
|
NSColor.white.setFill()
|
||||||
|
bar.fill()
|
||||||
|
|
||||||
|
// App dots sitting on the bar.
|
||||||
|
let dotColors: [NSColor] = [
|
||||||
|
NSColor(calibratedRed: 0.20, green: 0.42, blue: 0.95, alpha: 1),
|
||||||
|
NSColor(calibratedRed: 0.95, green: 0.55, blue: 0.15, alpha: 1),
|
||||||
|
NSColor(calibratedRed: 0.20, green: 0.70, blue: 0.40, alpha: 1),
|
||||||
|
]
|
||||||
|
let dotRadius: CGFloat = 44
|
||||||
|
var dotX: CGFloat = 200
|
||||||
|
for color in dotColors {
|
||||||
|
let rect = NSRect(x: dotX, y: 648, width: dotRadius * 2, height: dotRadius * 2)
|
||||||
|
color.setFill()
|
||||||
|
NSBezierPath(ovalIn: rect).fill()
|
||||||
|
dotX += 130
|
||||||
|
}
|
||||||
|
|
||||||
|
// A chevron marking the collapsible background section.
|
||||||
|
NSColor(calibratedWhite: 0.35, alpha: 1).setStroke()
|
||||||
|
let chevron = NSBezierPath()
|
||||||
|
chevron.move(to: NSPoint(x: 700, y: 730))
|
||||||
|
chevron.line(to: NSPoint(x: 750, y: 680))
|
||||||
|
chevron.line(to: NSPoint(x: 800, y: 730))
|
||||||
|
chevron.lineWidth = 34
|
||||||
|
chevron.lineCapStyle = .round
|
||||||
|
chevron.lineJoinStyle = .round
|
||||||
|
chevron.stroke()
|
||||||
|
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
|
||||||
|
guard let data = rep.representation(using: .png, properties: [:]) else { exit(1) }
|
||||||
|
if CommandLine.arguments.count < 2 { exit(1) }
|
||||||
|
do {
|
||||||
|
try data.write(to: URL(fileURLWithPath: CommandLine.arguments[1]))
|
||||||
|
} catch {
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
swift scripts/gen-icon.swift "$TMP/icon-1024.png"
|
||||||
|
|
||||||
|
mkdir -p "$TMP/Icon.iconset"
|
||||||
|
for s in 16 32 128 256 512; do
|
||||||
|
d=$((s * 2))
|
||||||
|
sips -z "$s" "$s" "$TMP/icon-1024.png" --out "$TMP/Icon.iconset/icon_${s}x${s}.png" >/dev/null
|
||||||
|
sips -z "$d" "$d" "$TMP/icon-1024.png" --out "$TMP/Icon.iconset/icon_${s}x${s}@2x.png" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
iconutil -c icns "$TMP/Icon.iconset" -o "$TMP/Icon.icns"
|
||||||
|
mkdir -p SimpleTopBar.app/Contents/Resources
|
||||||
|
cp "$TMP/Icon.icns" SimpleTopBar.app/Contents/Resources/Icon.icns
|
||||||
|
echo "Generated SimpleTopBar.app/Contents/Resources/Icon.icns"
|
||||||
Reference in New Issue
Block a user