commit 41a5917b42f2f03ac6826954efffa6bb02d9cda5 Author: TZGyn Date: Sat Sep 26 22:37:09 2026 +0800 Add SimpleTopBar menu bar app with collapsible background app list diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d304ef2 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7815706 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..e1d2f60 --- /dev/null +++ b/Package.swift @@ -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" + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..7fe6ebe --- /dev/null +++ b/README.md @@ -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 ` builds and zips the app to `dist/SimpleTopBar.zip` and +prints its sha256. Tag `v`, 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). diff --git a/Sources/SimpleTopBar/App.swift b/Sources/SimpleTopBar/App.swift new file mode 100644 index 0000000..bce4a06 --- /dev/null +++ b/Sources/SimpleTopBar/App.swift @@ -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 + } + } +} diff --git a/Sources/SimpleTopBar/AppListModel.swift b/Sources/SimpleTopBar/AppListModel.swift new file mode 100644 index 0000000..e538c78 --- /dev/null +++ b/Sources/SimpleTopBar/AppListModel.swift @@ -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() + } +} diff --git a/Sources/SimpleTopBar/SelfTest.swift b/Sources/SimpleTopBar/SelfTest.swift new file mode 100644 index 0000000..16d68aa --- /dev/null +++ b/Sources/SimpleTopBar/SelfTest.swift @@ -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) +} diff --git a/Sources/SimpleTopBar/TopBarView.swift b/Sources/SimpleTopBar/TopBarView.swift new file mode 100644 index 0000000..fd68c68 --- /dev/null +++ b/Sources/SimpleTopBar/TopBarView.swift @@ -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) } + } + } +} diff --git a/build-app.sh b/build-app.sh new file mode 100755 index 0000000..f3ba5c9 --- /dev/null +++ b/build-app.sh @@ -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" < + + + + CFBundleExecutable + SimpleTopBar + CFBundleIdentifier + dev.tzgyn.simpletopbar + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + SimpleTopBar + CFBundlePackageType + APPL + CFBundleIconFile + Icon + CFBundleShortVersionString + ${VERSION} + CFBundleVersion + ${VERSION} + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +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 diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..4d40725 --- /dev/null +++ b/release.sh @@ -0,0 +1,77 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")" + +if [ "$#" -ne 1 ]; then + echo "usage: $0 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="" +# export SIMPLETOPBAR_NOTARY_API_KEY_PATH="$HOME/.config/simpletopbar/AuthKey_XXXX.p8" +# export SIMPLETOPBAR_NOTARY_API_KEY_ID="" +# export SIMPLETOPBAR_NOTARY_API_ISSUER="" +# 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 diff --git a/scripts/gen-icon.swift b/scripts/gen-icon.swift new file mode 100644 index 0000000..5ee0d38 --- /dev/null +++ b/scripts/gen-icon.swift @@ -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) +} diff --git a/scripts/make-icon.sh b/scripts/make-icon.sh new file mode 100755 index 0000000..24b2d7f --- /dev/null +++ b/scripts/make-icon.sh @@ -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"