Skip to content

Commit 7d3ad0f

Browse files
ahmetrendeclaude
andcommitted
v1.0.3: finish audit backlog + new features
Fixes: - Match Dock tiles by bundle id (AXURL) so two apps sharing a name no longer cross-wire badges; fall back to title. - Don't walk the Dock for a monitored app that isn't running (a tile only exists for running apps) — kills the perpetual every-few-seconds walk. - Robust badge parsing: handles "9+" and non-numeric labels; extracted a pure, unit-tested isNewMessage(). - Deterministic candidate-title order (ordered dedupe instead of Set). - Pick the live (non-terminated) Dock process instead of an arbitrary one. - AlertPresenter serializes alerts (no overwrite / no stale fade hiding a fresh panel) and reuses a single cached icon service (AppIcons); dropped duplicate icon loaders. Features: - Configurable poll interval (1 / 2 / 5 s) — re-arms the timer live. - Per-app overrides (show count, floating alert) via the icon's right-click menu. - Drag-to-reorder monitored apps; the menu bar follows the new order. - "Check for Updates…" (GitHub Releases API) in the menus and Settings. - Turkish/English localization (resource-free table, follows system language). - Unit tests (Tests/) + GitHub Actions CI (.github/workflows/ci.yml). - Shared signing-config.sh so build.sh / setup-signing.sh can't drift. Bump to 1.0.3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ebfe8df commit 7d3ad0f

19 files changed

Lines changed: 736 additions & 235 deletions

.github/workflows/ci.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
build-test:
10+
runs-on: macos-14
11+
steps:
12+
- uses: actions/checkout@v4
13+
- name: Swift version
14+
run: swift --version
15+
- name: Build
16+
run: swift build
17+
- name: Test
18+
run: swift test

Info.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
<key>CFBundlePackageType</key>
1616
<string>APPL</string>
1717
<key>CFBundleShortVersionString</key>
18-
<string>1.0.2</string>
18+
<string>1.0.3</string>
1919
<key>CFBundleVersion</key>
20-
<string>3</string>
20+
<string>4</string>
2121
<key>LSMinimumSystemVersion</key>
2222
<string>14.0</string>
2323
<key>LSUIElement</key>

Package.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ let package = Package(
88
.executableTarget(
99
name: "BadgeBar",
1010
path: "Sources/BadgeBar"
11+
),
12+
.testTarget(
13+
name: "BadgeBarTests",
14+
dependencies: ["BadgeBar"],
15+
path: "Tests/BadgeBarTests"
1116
)
1217
],
1318
swiftLanguageModes: [.v5]

README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ icon appears there instead; click it to grant access.
9090

9191
The configuration window has two tabs:
9292

93-
- **Apps** — search installed apps and add/remove them from the menu bar.
93+
- **Apps** — search installed apps to add/remove them, and **drag the monitored
94+
list to reorder** how icons appear in the menu bar.
9495
- **Settings**:
9596
- **Menu bar**
9697
- *Show unread count* — off shows a small red dot instead of the number.
@@ -99,12 +100,20 @@ The configuration window has two tabs:
99100
- *Dim the icon when there's no notification* — greys it out instead of
100101
hiding.
101102
- *Hide an app when it isn't running* — removes the icon while closed.
103+
- *Always show a BadgeBar icon* — keep a permanent menu-bar entry.
102104
- **On-screen alert** — a floating alert that appears briefly on top of
103105
everything (including full-screen apps) when a new message arrives.
104106
Toggle on/off and choose how long it stays (2 / 4 / 6 s). Click it to open
105107
the app.
106-
- **General***Launch at login* (via `SMAppService`).
108+
- **General***Launch at login* (via `SMAppService`), a *Poll interval*
109+
(1 / 2 / 5 s) to trade latency for fewer wakeups, and *Check for Updates…*.
107110
- **Permissions** — Accessibility status with a deep-link to System Settings.
111+
If access is revoked while running, a warning icon appears in the menu bar.
112+
113+
**Per-app overrides:** right-click an app's menu-bar icon → *This app* to
114+
override *Show unread count* or the floating alert just for that app.
115+
116+
The UI is localized (English / Turkish, following your system language).
108117

109118
## Performance
110119

@@ -181,7 +190,13 @@ Sources/BadgeBar/
181190
## Contributing
182191

183192
Issues and pull requests are welcome. To hack on it: clone, run `./run.sh`, and
184-
edit. No Xcode required (though `Package.swift` opens in Xcode too).
193+
edit. No Xcode required to build the app (though `Package.swift` opens in Xcode
194+
too).
195+
196+
Unit tests cover the pure logic (badge-change detection, version comparison,
197+
model persistence). Run them with `swift test` — this needs XCTest, which ships
198+
with Xcode, so it runs in CI (GitHub Actions, `.github/workflows/ci.yml`) even
199+
if your local machine only has the Command Line Tools.
185200

186201
## License
187202

Sources/BadgeBar/AlertPresenter.swift

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,43 @@ final class AlertPresenter {
1010
private var hosting: NSHostingController<FloatingAlertView>?
1111
private var dismissTask: DispatchWorkItem?
1212

13+
/// Pending alerts shown one after another so a burst doesn't overwrite
14+
/// itself. At most one entry per app (latest badge wins).
15+
private var queue: [(app: MonitoredApp, badge: String)] = []
16+
private var showing = false
17+
18+
/// Whether the alert is enabled is decided by the caller (global + per-app);
19+
/// this just presents. Alerts are serialized so a new one never clobbers a
20+
/// visible one, and a stale fade-out can never hide a fresh panel.
1321
func show(app: MonitoredApp, badge: String) {
14-
guard Settings.floatingAlertEnabled else { return }
22+
queue.removeAll { $0.app.bundleId == app.bundleId }
23+
queue.append((app, badge))
24+
if !showing { presentNext() }
25+
}
26+
27+
private func presentNext() {
28+
guard !queue.isEmpty else {
29+
showing = false
30+
return
31+
}
32+
showing = true
33+
let (app, badge) = queue.removeFirst()
1534

16-
let icon = AppIconLoader.icon(forBundleId: app.bundleId)
17-
let view = FloatingAlertView(icon: icon, appName: app.name, badge: badge) {
35+
let view = FloatingAlertView(icon: AppIcons.icon(forBundleId: app.bundleId),
36+
appName: app.name,
37+
badge: badge) {
1838
AppLauncher.activate(bundleId: app.bundleId)
1939
}
2040

2141
let panel = ensurePanel()
2242
hosting?.rootView = view
2343
position(panel)
24-
25-
panel.alphaValue = 0
2644
panel.orderFrontRegardless()
27-
NSAnimationContext.runAnimationGroup { context in
28-
context.duration = 0.18
29-
panel.animator().alphaValue = 1
45+
if panel.alphaValue < 1 {
46+
NSAnimationContext.runAnimationGroup { context in
47+
context.duration = 0.18
48+
panel.animator().alphaValue = 1
49+
}
3050
}
3151

3252
scheduleDismiss()
@@ -81,12 +101,31 @@ final class AlertPresenter {
81101
}
82102

83103
private func dismiss() {
84-
guard let panel else { return }
104+
guard let panel else {
105+
showing = false
106+
presentNext()
107+
return
108+
}
109+
// If another alert is queued, hand the panel straight to it (no flicker);
110+
// otherwise fade out and hide.
111+
if !queue.isEmpty {
112+
presentNext()
113+
return
114+
}
85115
NSAnimationContext.runAnimationGroup { context in
86116
context.duration = 0.3
87117
panel.animator().alphaValue = 0
88-
} completionHandler: {
89-
panel.orderOut(nil)
118+
} completionHandler: { [weak self] in
119+
// NSAnimationContext completions run on the main thread.
120+
MainActor.assumeIsolated {
121+
guard let self else { return }
122+
if self.queue.isEmpty {
123+
panel.orderOut(nil)
124+
self.showing = false
125+
} else {
126+
self.presentNext()
127+
}
128+
}
90129
}
91130
}
92131
}
@@ -107,7 +146,7 @@ private struct FloatingAlertView: View {
107146
Text(appName.isEmpty ? " " : appName)
108147
.font(.headline)
109148
.lineLimit(1)
110-
Text("New notification")
149+
Text(L.t("New notification"))
111150
.font(.subheadline)
112151
.foregroundStyle(.secondary)
113152
}

Sources/BadgeBar/AppIcons.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import AppKit
2+
3+
/// Single cached source of app icons keyed by bundle id. NSCache evicts under
4+
/// memory pressure, so there's nothing to prune manually.
5+
enum AppIcons {
6+
private static let cache = NSCache<NSString, NSImage>()
7+
8+
static func icon(forBundleId bundleId: String) -> NSImage {
9+
if let hit = cache.object(forKey: bundleId as NSString) {
10+
return hit
11+
}
12+
let image: NSImage
13+
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) {
14+
image = NSWorkspace.shared.icon(forFile: url.path)
15+
} else {
16+
image = NSImage(systemSymbolName: "app.dashed", accessibilityDescription: nil) ?? NSImage()
17+
}
18+
cache.setObject(image, forKey: bundleId as NSString)
19+
return image
20+
}
21+
}

Sources/BadgeBar/BadgeMonitor.swift

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@ import Foundation
66
/// walk per second costs well under a millisecond.
77
@MainActor
88
final class BadgeMonitor {
9-
private static let interval: TimeInterval = 1.0
10-
119
private let store: AppStore
1210
private let reader = BadgeReader()
1311
private var timer: Timer?
12+
private var currentInterval: TimeInterval = 0
1413

1514
/// Previous badge text per bundle id, for detecting new messages.
1615
private var previousText: [String: String] = [:]
@@ -30,10 +29,33 @@ final class BadgeMonitor {
3029
}
3130

3231
func start() {
33-
timer = Timer.scheduledTimer(withTimeInterval: Self.interval, repeats: true) { [weak self] _ in
32+
scheduleTimer()
33+
timer?.fire()
34+
}
35+
36+
private func scheduleTimer() {
37+
timer?.invalidate()
38+
currentInterval = Settings.pollInterval
39+
timer = Timer.scheduledTimer(withTimeInterval: currentInterval, repeats: true) { [weak self] _ in
3440
MainActor.assumeIsolated { self?.tick() }
3541
}
36-
timer?.fire()
42+
}
43+
44+
/// Decides whether a badge change should raise a "new message" alert.
45+
/// Fires on first appearance, a numeric increase (handles "9+"), or any
46+
/// change involving non-numeric text. Pure so it's unit-testable.
47+
nonisolated static func isNewMessage(old: String, new: String) -> Bool {
48+
guard !new.isEmpty, new != old else { return false }
49+
if old.isEmpty { return true }
50+
if let newN = leadingInt(new), let oldN = leadingInt(old) {
51+
return newN > oldN
52+
}
53+
return true
54+
}
55+
56+
nonisolated private static func leadingInt(_ text: String) -> Int? {
57+
let digits = text.prefix { $0.isNumber }
58+
return digits.isEmpty ? nil : Int(digits)
3759
}
3860

3961
private func tick() {
@@ -74,15 +96,17 @@ final class BadgeMonitor {
7496
continue
7597
}
7698

77-
// Fire on a numeric increase, or an empty→non-empty transition.
78-
let isNew = !newText.isEmpty
79-
&& newText != oldText
80-
&& ((Int(newText) ?? 0) > (Int(oldText) ?? 0) || oldText.isEmpty)
81-
if isNew {
99+
if Self.isNewMessage(old: oldText, new: newText),
100+
app.alerts(default: Settings.floatingAlertEnabled) {
82101
onNewMessage?(app, newText)
83102
}
84103
}
85104
}
86105
onTick?()
106+
107+
// Re-arm the timer if the user changed the poll interval.
108+
if Settings.pollInterval != currentInterval {
109+
scheduleTimer()
110+
}
87111
}
88112
}

0 commit comments

Comments
 (0)