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..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 } @@ -16,9 +44,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 +73,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 +81,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 +126,7 @@ private final class ArcadeKeyboardCapture: NSObject, NSWindowDelegate { } func windowDidResignKey(_ notification: Notification) { - guard !isStopping else { return } + guard !isStopping, captureEstablished else { return } onFocusLost?() } } @@ -91,6 +139,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") @@ -109,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) { @@ -125,7 +174,7 @@ final class ArcadeController { } selectedGame = game engine.select(game) - generation += 1 + uploadLifecycle.beginSession() isActive = true framesSent = 0 framesDropped = 0 @@ -135,13 +184,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) { @@ -163,14 +226,16 @@ 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 keyboard.stop() if let error { errorMessage = error @@ -210,14 +275,22 @@ 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: 1, priority: 99 ) @@ -226,8 +299,17 @@ final class ArcadeController { 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 @@ -240,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 1a86ea7..5cec911 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] = [ + "application_name": appName, + "priority": priority, + "elements": elements, + ] + if let ledColor { + payload["led_notification_color"] = ledColor + } + return payload + } + + static func assetQuery(filename: String) -> [String: String] { + ["application_name": 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,14 +255,11 @@ 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)) } @@ -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/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 new file mode 100644 index 0000000..6a3e1eb --- /dev/null +++ b/Tests/BarKeepTests/BusyBarClientTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import BarKeep + +final class BusyBarClientTests: XCTestCase { + func testDisplayPayloadUsesFirmwareApplicationNamespace() { + let payload = BusyBarClient.displayPayload( + elements: [["id": "frame", "type": "image", "path": "arcade.png"]], + priority: 95 + ) + + XCTAssertEqual(payload["application_name"] as? String, BusyBarClient.appName) + XCTAssertNil(payload["app_id"]) + XCTAssertEqual(payload["priority"] as? Int, 95) + } + + func testAssetUploadUsesSameFirmwareApplicationNamespace() { + let query = BusyBarClient.assetQuery(filename: "arcade.png") + + XCTAssertEqual(query["application_name"], BusyBarClient.appName) + XCTAssertEqual(query["file"], "arcade.png") + 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