From 8e18d232059695515057a4ec337ade85df8d1eeb Mon Sep 17 00:00:00 2001 From: Brian Phillips Date: Thu, 23 Jul 2026 12:03:26 -0500 Subject: [PATCH 1/2] fix: support current Busy Bar arcade API --- README.md | 3 ++ Sources/BarKeep/ArcadeController.swift | 50 ++++++++++++++++++--- Sources/BarKeep/BusyBarClient.swift | 37 ++++++++++----- Sources/BarKeep/MenuView.swift | 19 ++++++-- Tests/BarKeepTests/BusyBarClientTests.swift | 23 ++++++++++ 5 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 Tests/BarKeepTests/BusyBarClientTests.swift diff --git a/README.md b/README.md index fc9b327..11818af 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,9 @@ the Arcade tab. Games cannot run while a native busy/timer session is active, because Busy Bar firmware rejects custom drawing during those sessions. Starting an on-call session stops the arcade automatically. +If another Mac app takes keyboard focus, the game remains active on the Busy +Bar. Return to the Arcade tab and click **Capture Keyboard** to resume controls. + ## Configuration Everything is configured in the app's Settings tab — device host, API token (needed for Wi-Fi), busy theme, notification filter, Slack token, ping target, weather unit and location (type a city, it's geocoded for you; leave empty for automatic IP-based location). No config files, no terminal required. diff --git a/Sources/BarKeep/ArcadeController.swift b/Sources/BarKeep/ArcadeController.swift index 3c8011d..0f5bcb2 100644 --- a/Sources/BarKeep/ArcadeController.swift +++ b/Sources/BarKeep/ArcadeController.swift @@ -16,9 +16,12 @@ private final class ArcadeKeyboardCapture: NSObject, NSWindowDelegate { private var previousApplication: NSRunningApplication? private var onFocusLost: (() -> Void)? private var isStopping = false + private var captureTask: Task? + private var captureEstablished = false func start( onEvent: @escaping (NSEvent) -> Void, + onCaptured: @escaping () -> Void, onFocusLost: @escaping () -> Void ) { stop(restoreFocus: false) @@ -42,9 +45,6 @@ private final class ArcadeKeyboardCapture: NSObject, NSWindowDelegate { panel.level = .floating panel.collectionBehavior = [.canJoinAllSpaces, .transient, .ignoresCycle] panel.delegate = self - panel.orderFrontRegardless() - NSApp.activate(ignoringOtherApps: true) - panel.makeKey() self.panel = panel eventMonitor = NSEvent.addLocalMonitorForEvents( @@ -53,10 +53,30 @@ private final class ArcadeKeyboardCapture: NSObject, NSWindowDelegate { onEvent(event) return nil } + + // The menu-bar popover is still completing its button click when this + // method starts. Capturing immediately lets that popover steal key + // status back and looks like an external focus change. + captureTask = Task { [weak self, weak panel] in + try? await Task.sleep(for: .milliseconds(150)) + guard !Task.isCancelled, let self, let panel, self.panel === panel else { + return + } + panel.orderFrontRegardless() + NSApp.activate(ignoringOtherApps: true) + panel.makeKey() + self.captureEstablished = panel.isKeyWindow + if self.captureEstablished { + onCaptured() + } + } } func stop(restoreFocus: Bool = true) { isStopping = true + captureTask?.cancel() + captureTask = nil + captureEstablished = false if let eventMonitor { NSEvent.removeMonitor(eventMonitor) self.eventMonitor = nil @@ -78,7 +98,7 @@ private final class ArcadeKeyboardCapture: NSObject, NSWindowDelegate { } func windowDidResignKey(_ notification: Notification) { - guard !isStopping else { return } + guard !isStopping, captureEstablished else { return } onFocusLost?() } } @@ -91,6 +111,7 @@ final class ArcadeController { private(set) var previewImage: CGImage? private(set) var framesSent = 0 private(set) var framesDropped = 0 + private(set) var controlsCaptured = false var showPreview: Bool { didSet { UserDefaults.standard.set(showPreview, forKey: "arcadeShowPreview") @@ -135,13 +156,27 @@ final class ArcadeController { heldKeys.removeAll() pressedKeys.removeAll() updatePreview() + captureKeyboard() + startLoop() + } + + func captureKeyboard() { + guard isActive else { return } + controlsCaptured = false keyboard.start( onEvent: { [weak self] event in self?.handle(event) }, + onCaptured: { [weak self] in + self?.controlsCaptured = true + self?.errorMessage = nil + }, onFocusLost: { [weak self] in - self?.stop(withError: "Arcade stopped because keyboard focus changed.") + guard let self else { return } + self.controlsCaptured = false + self.heldKeys.removeAll() + self.pressedKeys.removeAll() + self.errorMessage = "Keyboard focus released. Click Capture Keyboard to resume controls." } ) - startLoop() } func select(_ game: ArcadeGame) { @@ -171,6 +206,7 @@ final class ArcadeController { uploadTask = nil heldKeys.removeAll() pressedKeys.removeAll() + controlsCaptured = false keyboard.stop() if let error { errorMessage = error @@ -219,7 +255,7 @@ final class ArcadeController { try await self.client.uploadAsset(filename: filename, data: png) guard self.isActive, self.generation == frameGeneration else { return } try await self.client.drawImage( - named: filename, timeout: 1, priority: 99 + named: filename, timeout: 6, priority: 95 ) self.framesSent += 1 self.consecutiveFailures = 0 diff --git a/Sources/BarKeep/BusyBarClient.swift b/Sources/BarKeep/BusyBarClient.swift index 1a86ea7..f59deed 100644 --- a/Sources/BarKeep/BusyBarClient.swift +++ b/Sources/BarKeep/BusyBarClient.swift @@ -200,6 +200,26 @@ final class BusyBarClient: @unchecked Sendable { static let displayWidth = 72 static let displayHeight = 16 + static func displayPayload( + elements: [[String: Any]], + priority: Int, + ledColor: String? = nil + ) -> [String: Any] { + var payload: [String: Any] = [ + "app_id": appName, + "priority": priority, + "elements": elements, + ] + if let ledColor { + payload["led_notification_color"] = ledColor + } + return payload + } + + static func assetQuery(filename: String) -> [String: String] { + ["app_id": appName, "file": filename] + } + func drawText(_ text: String, font: TextFont, colorHex: String, timeout: Int, priority: Int, ledColor: String? = nil) async throws { let element: [String: Any] = [ "id": "msg", @@ -235,20 +255,17 @@ final class BusyBarClient: @unchecked Sendable { } private func draw(elements: [[String: Any]], priority: Int, ledColor: String? = nil) async throws { - var payload: [String: Any] = [ - "application_name": Self.appName, - "priority": priority, - "elements": elements, - ] - if let ledColor { - payload["led_notification_color"] = ledColor - } + let payload = Self.displayPayload( + elements: elements, + priority: priority, + ledColor: ledColor + ) let body = try JSONSerialization.data(withJSONObject: payload) try await send(try request("POST", "/display/draw", body: body)) } func clearDisplay() async throws { - try await send(try request("DELETE", "/display/draw", query: ["application_name": Self.appName])) + try await send(try request("DELETE", "/display/draw", query: ["app_id": Self.appName])) } func drawCountdown(to date: Date, colorHex: String, timeout: Int, priority: Int) async throws { @@ -434,7 +451,7 @@ final class BusyBarClient: @unchecked Sendable { func uploadAsset(filename: String, data: Data) async throws { let req = try request( "POST", "/assets/upload", - query: ["application_name": Self.appName, "file": filename], + query: Self.assetQuery(filename: filename), body: data, contentType: "application/octet-stream" ) diff --git a/Sources/BarKeep/MenuView.swift b/Sources/BarKeep/MenuView.swift index ccdd210..a26c729 100644 --- a/Sources/BarKeep/MenuView.swift +++ b/Sources/BarKeep/MenuView.swift @@ -167,9 +167,22 @@ struct ArcadeTab: View { Button("Stop") { arcade.stop() } .controlSize(.small) } - Text("Keyboard captured · 1–4 switch games · R restarts · Esc stops") - .font(.caption2) - .foregroundStyle(.secondary) + if arcade.controlsCaptured { + Text("Keyboard captured · 1–4 switch games · R restarts · Esc stops") + .font(.caption2) + .foregroundStyle(.secondary) + } else { + HStack { + Text("Keyboard controls are paused.") + .font(.caption2) + .foregroundStyle(.orange) + Spacer() + Button("Capture Keyboard") { + arcade.captureKeyboard() + } + .controlSize(.small) + } + } Text("\(arcade.framesSent) frames sent · \(arcade.framesDropped) skipped") .font(.caption2.monospacedDigit()) .foregroundStyle(.tertiary) diff --git a/Tests/BarKeepTests/BusyBarClientTests.swift b/Tests/BarKeepTests/BusyBarClientTests.swift new file mode 100644 index 0000000..f18e05f --- /dev/null +++ b/Tests/BarKeepTests/BusyBarClientTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import BarKeep + +final class BusyBarClientTests: XCTestCase { + func testDisplayPayloadUsesFirmwareAppIDNamespace() { + let payload = BusyBarClient.displayPayload( + elements: [["id": "frame", "type": "image", "path": "arcade.png"]], + priority: 95 + ) + + XCTAssertEqual(payload["app_id"] as? String, BusyBarClient.appName) + XCTAssertNil(payload["application_name"]) + XCTAssertEqual(payload["priority"] as? Int, 95) + } + + func testAssetUploadUsesSameFirmwareAppIDNamespace() { + let query = BusyBarClient.assetQuery(filename: "arcade.png") + + XCTAssertEqual(query["app_id"], BusyBarClient.appName) + XCTAssertEqual(query["file"], "arcade.png") + XCTAssertNil(query["application_name"]) + } +} From 4c527cd5567bf7248755ff4c488bd95f047acd42 Mon Sep 17 00:00:00 2001 From: Brian Phillips Date: Thu, 23 Jul 2026 12:26:53 -0500 Subject: [PATCH 2/2] fix: keep arcade uploads intact across focus changes --- Sources/BarKeep/ArcadeController.swift | 66 +++++++++++++++++---- Sources/BarKeep/BusyBarClient.swift | 6 +- Tests/BarKeepTests/ArcadeEngineTests.swift | 15 +++++ Tests/BarKeepTests/BusyBarClientTests.swift | 12 ++-- packaging/Info.plist | 2 +- 5 files changed, 81 insertions(+), 20 deletions(-) diff --git a/Sources/BarKeep/ArcadeController.swift b/Sources/BarKeep/ArcadeController.swift index 0f5bcb2..26224a8 100644 --- a/Sources/BarKeep/ArcadeController.swift +++ b/Sources/BarKeep/ArcadeController.swift @@ -4,6 +4,34 @@ import os private let arcadeLog = Logger(subsystem: "dev.barkeep.mac", category: "arcade") +struct ArcadeUploadLifecycle { + private(set) var session = 0 + private(set) var uploadSession: Int? + + mutating func beginSession() { + session += 1 + } + + mutating func beginUpload() -> Int { + uploadSession = session + return session + } + + mutating func invalidateSession() { + session += 1 + } + + func shouldDraw(uploadSession: Int, isActive: Bool) -> Bool { + isActive && uploadSession == session + } + + mutating func finishUpload(_ finishedSession: Int) -> Bool { + guard uploadSession == finishedSession else { return false } + uploadSession = nil + return true + } +} + private final class ArcadeInputPanel: NSPanel { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } @@ -130,7 +158,7 @@ final class ArcadeController { private var uploadSlot = 0 private var lastUpload = ContinuousClock.now private var nextUploadAllowed = ContinuousClock.now - private var generation = 0 + private var uploadLifecycle = ArcadeUploadLifecycle() private var consecutiveFailures = 0 init(client: BusyBarClient) { @@ -146,7 +174,7 @@ final class ArcadeController { } selectedGame = game engine.select(game) - generation += 1 + uploadLifecycle.beginSession() isActive = true framesSent = 0 framesDropped = 0 @@ -198,12 +226,13 @@ final class ArcadeController { private func stop(withError error: String?) { guard isActive else { return } - generation += 1 + uploadLifecycle.invalidateSession() isActive = false gameTask?.cancel() gameTask = nil - uploadTask?.cancel() - uploadTask = nil + // URLSession cancellation can leave a partially overwritten PNG in + // the device asset slot. Let the current upload finish; generation + // checks below prevent its stale frame from being drawn. heldKeys.removeAll() pressedKeys.removeAll() controlsCaptured = false @@ -246,24 +275,41 @@ final class ArcadeController { return } lastUpload = now - let frameGeneration = generation + let frameGeneration = uploadLifecycle.beginUpload() let filename = "arcade\(uploadSlot).png" uploadSlot = (uploadSlot + 1) % 2 uploadTask = Task { [weak self] in guard let self else { return } do { try await self.client.uploadAsset(filename: filename, data: png) - guard self.isActive, self.generation == frameGeneration else { return } + guard self.uploadLifecycle.shouldDraw( + uploadSession: frameGeneration, + isActive: self.isActive + ) else { + if self.uploadLifecycle.finishUpload(frameGeneration) { + self.uploadTask = nil + } + return + } try await self.client.drawImage( - named: filename, timeout: 6, priority: 95 + named: filename, timeout: 1, priority: 99 ) self.framesSent += 1 self.consecutiveFailures = 0 self.nextUploadAllowed = .now self.errorMessage = nil } catch is CancellationError { - // Stopping the arcade intentionally cancels an in-flight frame. + // Process shutdown can still cancel URLSession work. } catch { + guard self.uploadLifecycle.shouldDraw( + uploadSession: frameGeneration, + isActive: self.isActive + ) else { + if self.uploadLifecycle.finishUpload(frameGeneration) { + self.uploadTask = nil + } + return + } arcadeLog.error("Arcade frame failed: \(error.localizedDescription, privacy: .public)") self.errorMessage = error.localizedDescription self.consecutiveFailures += 1 @@ -276,7 +322,7 @@ final class ArcadeController { self.stop(withError: "Arcade stopped after repeated connection failures.") } } - if self.generation == frameGeneration { + if self.uploadLifecycle.finishUpload(frameGeneration) { self.uploadTask = nil } } diff --git a/Sources/BarKeep/BusyBarClient.swift b/Sources/BarKeep/BusyBarClient.swift index f59deed..5cec911 100644 --- a/Sources/BarKeep/BusyBarClient.swift +++ b/Sources/BarKeep/BusyBarClient.swift @@ -206,7 +206,7 @@ final class BusyBarClient: @unchecked Sendable { ledColor: String? = nil ) -> [String: Any] { var payload: [String: Any] = [ - "app_id": appName, + "application_name": appName, "priority": priority, "elements": elements, ] @@ -217,7 +217,7 @@ final class BusyBarClient: @unchecked Sendable { } static func assetQuery(filename: String) -> [String: String] { - ["app_id": appName, "file": filename] + ["application_name": appName, "file": filename] } func drawText(_ text: String, font: TextFont, colorHex: String, timeout: Int, priority: Int, ledColor: String? = nil) async throws { @@ -265,7 +265,7 @@ final class BusyBarClient: @unchecked Sendable { } func clearDisplay() async throws { - try await send(try request("DELETE", "/display/draw", query: ["app_id": Self.appName])) + try await send(try request("DELETE", "/display/draw", query: ["application_name": Self.appName])) } func drawCountdown(to date: Date, colorHex: String, timeout: Int, priority: Int) async throws { diff --git a/Tests/BarKeepTests/ArcadeEngineTests.swift b/Tests/BarKeepTests/ArcadeEngineTests.swift index 9ce31ef..accecc4 100644 --- a/Tests/BarKeepTests/ArcadeEngineTests.swift +++ b/Tests/BarKeepTests/ArcadeEngineTests.swift @@ -2,6 +2,21 @@ import XCTest @testable import BarKeep final class ArcadeEngineTests: XCTestCase { + func testStoppingInvalidatesFrameWithoutAbandoningUpload() { + var lifecycle = ArcadeUploadLifecycle() + lifecycle.beginSession() + let uploadSession = lifecycle.beginUpload() + + lifecycle.invalidateSession() + + XCTAssertEqual(lifecycle.uploadSession, uploadSession) + XCTAssertFalse( + lifecycle.shouldDraw(uploadSession: uploadSession, isActive: false) + ) + XCTAssertTrue(lifecycle.finishUpload(uploadSession)) + XCTAssertNil(lifecycle.uploadSession) + } + func testEveryGameRendersAVisibleNativeResolutionFrame() { for game in ArcadeGame.allCases { let engine = ArcadeEngine(game: game) diff --git a/Tests/BarKeepTests/BusyBarClientTests.swift b/Tests/BarKeepTests/BusyBarClientTests.swift index f18e05f..6a3e1eb 100644 --- a/Tests/BarKeepTests/BusyBarClientTests.swift +++ b/Tests/BarKeepTests/BusyBarClientTests.swift @@ -2,22 +2,22 @@ import XCTest @testable import BarKeep final class BusyBarClientTests: XCTestCase { - func testDisplayPayloadUsesFirmwareAppIDNamespace() { + func testDisplayPayloadUsesFirmwareApplicationNamespace() { let payload = BusyBarClient.displayPayload( elements: [["id": "frame", "type": "image", "path": "arcade.png"]], priority: 95 ) - XCTAssertEqual(payload["app_id"] as? String, BusyBarClient.appName) - XCTAssertNil(payload["application_name"]) + XCTAssertEqual(payload["application_name"] as? String, BusyBarClient.appName) + XCTAssertNil(payload["app_id"]) XCTAssertEqual(payload["priority"] as? Int, 95) } - func testAssetUploadUsesSameFirmwareAppIDNamespace() { + func testAssetUploadUsesSameFirmwareApplicationNamespace() { let query = BusyBarClient.assetQuery(filename: "arcade.png") - XCTAssertEqual(query["app_id"], BusyBarClient.appName) + XCTAssertEqual(query["application_name"], BusyBarClient.appName) XCTAssertEqual(query["file"], "arcade.png") - XCTAssertNil(query["application_name"]) + XCTAssertNil(query["app_id"]) } } diff --git a/packaging/Info.plist b/packaging/Info.plist index 11f94be..c827d25 100644 --- a/packaging/Info.plist +++ b/packaging/Info.plist @@ -13,7 +13,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.10 + 1.0.11 LSMinimumSystemVersion 14.0 LSUIElement