Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
112 changes: 97 additions & 15 deletions Sources/BarKeep/ArcadeController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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<Void, Never>?
private var captureEstablished = false

func start(
onEvent: @escaping (NSEvent) -> Void,
onCaptured: @escaping () -> Void,
onFocusLost: @escaping () -> Void
) {
stop(restoreFocus: false)
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -78,7 +126,7 @@ private final class ArcadeKeyboardCapture: NSObject, NSWindowDelegate {
}

func windowDidResignKey(_ notification: Notification) {
guard !isStopping else { return }
guard !isStopping, captureEstablished else { return }
onFocusLost?()
}
}
Expand All @@ -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")
Expand All @@ -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) {
Expand All @@ -125,7 +174,7 @@ final class ArcadeController {
}
selectedGame = game
engine.select(game)
generation += 1
uploadLifecycle.beginSession()
isActive = true
framesSent = 0
framesDropped = 0
Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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
)
Expand All @@ -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
Expand All @@ -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
}
}
Expand Down
35 changes: 26 additions & 9 deletions Sources/BarKeep/BusyBarClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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"
)
Expand Down
19 changes: 16 additions & 3 deletions Sources/BarKeep/MenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions Tests/BarKeepTests/ArcadeEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions Tests/BarKeepTests/BusyBarClientTests.swift
Original file line number Diff line number Diff line change
@@ -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"])
}
}
2 changes: 1 addition & 1 deletion packaging/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.10</string>
<string>1.0.11</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
Loading