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
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A macOS menu bar companion for the [Busy Bar](https://busy.app) — control your bar over USB or Wi-Fi, automate your busy status, and turn the little LED display into a proper developer peripheral.

No cloud, no account, no telemetry: BarKeep talks directly to the bar's local HTTP API, either over USB (`http://10.0.4.20/api`, no authentication) or Wi-Fi (the bar's local IP address and API token).
No cloud, no account, no telemetry: BarKeep talks directly to the bar's local HTTP API, either over USB (`http://10.0.4.20/api`, no authentication) or Wi-Fi (the bar's local IP address and local HTTP API password).

## Features

Expand Down Expand Up @@ -102,7 +102,7 @@ For a Busy Bar reached over Wi-Fi:
```bash
codex mcp add barkeep \
--env BARKEEP_HOST=YOUR_BAR_IP \
--env BARKEEP_TOKEN=YOUR_API_TOKEN \
--env BARKEEP_TOKEN=YOUR_HTTP_API_PASSWORD \
-- /usr/bin/python3 "$(brew --prefix barkeep-cli)/libexec/barkeep_mcp.py"
```

Expand Down Expand Up @@ -172,15 +172,18 @@ 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.
Everything is configured in the app's Settings tab — device host, local HTTP API password (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.

CLI env: `BARKEEP_HOST` (device address, default `10.0.4.20`), `BARKEEP_THEME` (busy theme, default `on_air`).
CLI env: `BARKEEP_HOST` (device address, default `10.0.4.20`),
`BARKEEP_TOKEN` (the local HTTP API password for Wi-Fi), and
`BARKEEP_THEME` (busy theme, default `on_air`).

## Device API notes

Verified against firmware 1.0.2 / API 24.3.0 ([official docs](https://api.busy.app/busybar/docs)):
Verified against firmware 1.0.2 / API 24.3.0
([official local HTTP API docs](https://docs.busy.app/bar/dev/http-api)):

- Over USB the API is served at `http://10.0.4.20/api/*` with no authentication. Over Wi-Fi, enter the bar's local IP address and bearer token in Settings. The docs' `/busybar/*` prefix is for the cloud proxy; BarKeep communicates with the device locally.
- Over USB the API is served at `http://10.0.4.20/api/*` with no authentication. Over Wi-Fi, enable HTTP API access in the bar's local web interface, configure its numeric password, then enter that same password in BarKeep Settings. BarKeep sends it using the firmware API's documented `X-API-Token` header. API tokens generated at `cloud.busy.app` are for the internet API and do not authenticate requests to a local IP address. The docs' `/busybar/*` prefix is for the cloud proxy; BarKeep communicates with the device locally.
- Text elements accept printable ASCII only (bitmap fonts); BarKeep renders emoji/unicode to PNGs and uploads them as assets.
- `/api/screen` returns base64 of raw **GRB** pixel data (LED byte order), 72×16×3.
- The firmware rejects *all* draw requests while a busy session is active, regardless of priority.
Expand Down
23 changes: 22 additions & 1 deletion Sources/BarKeep/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ final class AppState {
// MARK: - Settings (persisted)

var host: String {
didSet { UserDefaults.standard.set(host, forKey: "host"); client.host = host }
didSet {
UserDefaults.standard.set(host, forKey: "host")
client.host = host
if !deviceReachable {
transportType = Self.configuredTransport(for: host)
}
}
}
var token: String {
didSet { UserDefaults.standard.set(token, forKey: "token"); client.token = token }
Expand Down Expand Up @@ -117,6 +123,7 @@ final class AppState {
private(set) var activeMicrophoneNames: [String] = []
private(set) var onCall = false
private(set) var deviceReachable = false
private(set) var authenticationRejected = false
private(set) var batteryCharge: Int?
private(set) var firmwareVersion: String?
private(set) var availableThemes = [
Expand Down Expand Up @@ -207,6 +214,7 @@ final class AppState {
self.pingHost = defaults.string(forKey: "pingHost") ?? "1.1.1.1"
self.weatherCelsius = defaults.bool(forKey: "weatherCelsius")
self.client = BusyBarClient(host: host, token: token)
self.transportType = Self.configuredTransport(for: host)
self.arcade = ArcadeController(client: self.client)
localNetworkPermissionTrigger.onAccessAvailable = { [weak self] in
Task { @MainActor [weak self] in
Expand Down Expand Up @@ -243,6 +251,7 @@ final class AppState {
do {
let status = try await client.status()
deviceReachable = true
authenticationRejected = false
batteryCharge = status.power.battery_charge
firmwareVersion = status.firmware.version
let busyType = try await client.currentBusyType()
Expand All @@ -260,13 +269,25 @@ final class AppState {
if let value = try? await client.deviceName() { deviceNameText = value }
} catch {
deviceReachable = false
authenticationRejected = Self.isAuthenticationError(error)
batteryCharge = nil
transportType = Self.configuredTransport(for: host)
if arcade.isActive {
arcade.stop()
}
}
}

nonisolated static func configuredTransport(for host: String) -> String {
BusyBarClient.normalizedHost(host) == "10.0.4.20"
? "usb"
: "wifi"
}

nonisolated static func isAuthenticationError(_ error: Error) -> Bool {
(error as? BusyBarError)?.statusCode == 403
}

/// Runs while the popover is visible; keeps the live preview fresh.
func previewLoop() async {
while !Task.isCancelled {
Expand Down
44 changes: 39 additions & 5 deletions Sources/BarKeep/BusyBarClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation

struct BusyBarError: Error, LocalizedError {
let message: String
var statusCode: Int? = nil
var errorDescription: String? { message }
}

Expand Down Expand Up @@ -51,7 +52,7 @@ final class BusyBarClient: @unchecked Sendable {
private let session: URLSession

init(host: String, token: String = "") {
self.host = host
self.host = Self.normalizedHost(host)
self.token = token
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 6
Expand All @@ -60,7 +61,8 @@ final class BusyBarClient: @unchecked Sendable {
}

private func request(_ method: String, _ path: String, query: [String: String] = [:], body: Data? = nil, contentType: String = "application/json") throws -> URLRequest {
var components = URLComponents(string: "http://\(host)/api\(path)")
let normalizedHost = Self.normalizedHost(host)
var components = URLComponents(string: "http://\(normalizedHost)/api\(path)")
if !query.isEmpty {
components?.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
}
Expand All @@ -73,10 +75,39 @@ final class BusyBarClient: @unchecked Sendable {
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
req.httpBody = body
}
try Self.applyAuthentication(to: &req, token: token)
return req
}

static func applyAuthentication(to request: inout URLRequest, token: String) throws {
if token.rangeOfCharacter(from: .newlines.union(.controlCharacters)) != nil {
throw BusyBarError(
message: "Wi-Fi password contains pasted line breaks. Clear it and enter only the Busy Bar's local HTTP API password."
)
}
if !token.isEmpty {
req.setValue("bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue(token, forHTTPHeaderField: "X-API-Token")
}
return req
}

static func normalizedHost(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let components = URLComponents(string: trimmed),
components.scheme != nil,
let hostname = components.host {
if let port = components.port {
return "\(hostname):\(port)"
}
return hostname
}
return trimmed
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
.components(separatedBy: "/")
.first ?? trimmed
}

static func webInterfaceURL(for host: String) -> URL? {
URL(string: "http://\(normalizedHost(host))/")
}

@discardableResult
Expand All @@ -87,7 +118,10 @@ final class BusyBarClient: @unchecked Sendable {
}
guard (200..<300).contains(http.statusCode) else {
let bodyText = String(data: data, encoding: .utf8) ?? ""
throw BusyBarError(message: "HTTP \(http.statusCode) \(req.url?.path ?? ""): \(bodyText.prefix(200))")
throw BusyBarError(
message: "HTTP \(http.statusCode) \(req.url?.path ?? ""): \(bodyText.prefix(200))",
statusCode: http.statusCode
)
}
return data
}
Expand Down
89 changes: 79 additions & 10 deletions Sources/BarKeep/MenuView.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,55 @@
import SwiftUI

struct CompactSecureField: NSViewRepresentable {
@Binding var text: String
let placeholder: String

func makeCoordinator() -> Coordinator {
Coordinator(text: $text)
}

func makeNSView(context: Context) -> NSSecureTextField {
let field = NSSecureTextField()
field.placeholderString = placeholder
field.isBezeled = true
field.isBordered = true
field.drawsBackground = true
field.focusRingType = .default
field.delegate = context.coordinator
field.stringValue = text
return field
}

func updateNSView(_ field: NSSecureTextField, context: Context) {
context.coordinator.text = $text
field.placeholderString = placeholder
if field.stringValue != text {
field.stringValue = text
}
}

func sizeThatFits(
_ proposal: ProposedViewSize,
nsView: NSSecureTextField,
context: Context
) -> CGSize? {
CGSize(width: proposal.width ?? 140, height: 22)
}

final class Coordinator: NSObject, NSTextFieldDelegate {
var text: Binding<String>

init(text: Binding<String>) {
self.text = text
}

func controlTextDidChange(_ notification: Notification) {
guard let field = notification.object as? NSTextField else { return }
text.wrappedValue = field.stringValue
}
}
}

@MainActor
struct MenuView: View {
@Environment(AppState.self) private var state
Expand Down Expand Up @@ -61,7 +111,11 @@ struct MenuView: View {
Circle()
.fill(state.deviceReachable ? .green : .orange)
.frame(width: 9, height: 9)
Text(state.deviceReachable ? "Busy Bar" : "Unreachable")
Text(
state.deviceReachable
? "Busy Bar"
: state.authenticationRejected ? "Token rejected" : "Unreachable"
)
.font(.headline)
Text(state.transportType.uppercased())
.font(.caption2)
Expand All @@ -75,14 +129,14 @@ struct MenuView: View {
.foregroundStyle(.secondary)
}
Button {
if let url = URL(string: "http://\(state.host)/") {
if let url = BusyBarClient.webInterfaceURL(for: state.host) {
NSWorkspace.shared.open(url)
}
} label: {
Image(systemName: "globe")
}
.buttonStyle(.borderless)
.help("Open the bar's web interface (http://\(state.host))")
.help("Open the bar's web interface (http://\(BusyBarClient.normalizedHost(state.host)))")
Button {
NSApp.terminate(nil)
} label: {
Expand Down Expand Up @@ -657,10 +711,23 @@ struct SettingsTab: View {
.frame(width: 140)
.onSubmit { state.applyDeviceName() }
}
LabeledContent("API token") {
SecureField("needed for Wi-Fi", text: $state.token)
.textFieldStyle(.roundedBorder)
.frame(width: 140)
LabeledContent("Wi-Fi password") {
CompactSecureField(
text: $state.token,
placeholder: "HTTP API password"
)
.frame(width: 140, height: 22)
}
if state.authenticationRejected {
Text("Busy Bar reached, but it rejected the local HTTP API password (HTTP 403). Cloud API tokens do not work with a local IP address.")
.font(.caption2)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
} else if !state.deviceReachable && state.transportType == "wifi" {
Text("Wi-Fi device unreachable. Confirm the IP and use a network that allows devices to communicate with each other. Some phone hotspots isolate connected devices.")
.font(.caption2)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
Picker("On-call theme", selection: $state.theme) {
ForEach(state.availableThemes, id: \.self) { theme in
Expand Down Expand Up @@ -726,9 +793,11 @@ struct SettingsTab: View {
Toggle("Sync Slack status when busy", isOn: $state.slackSyncEnabled)
if state.slackSyncEnabled {
LabeledContent("User token") {
SecureField("xoxp-…", text: $state.slackToken)
.textFieldStyle(.roundedBorder)
.frame(width: 160)
CompactSecureField(
text: $state.slackToken,
placeholder: "xoxp-…"
)
.frame(width: 160, height: 22)
}
Text("Sets “🎧 On a call” + DND while busy; clears after. Token needs users.profile:write and dnd:write — see README.")
.font(.caption2)
Expand Down
28 changes: 28 additions & 0 deletions Tests/BarKeepTests/AppVersionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,32 @@ final class AppVersionTests: XCTestCase {
func testFallsBackForDevelopmentBuilds() {
XCTAssertEqual(AppVersion.displayVersion(from: nil), "development")
}

func testConfiguredTransportUsesUSBOnlyForTheUSBInterface() {
XCTAssertEqual(AppState.configuredTransport(for: "10.0.4.20"), "usb")
XCTAssertEqual(AppState.configuredTransport(for: " 10.0.4.20 "), "usb")
XCTAssertEqual(AppState.configuredTransport(for: "172.20.10.9"), "wifi")
XCTAssertEqual(AppState.configuredTransport(for: "busybar.local"), "wifi")
}

func testAuthenticationFailureIsDistinguishedFromReachability() {
XCTAssertTrue(
AppState.isAuthenticationError(
BusyBarError(
message: "HTTP 403 /status: Forbidden",
statusCode: 403
)
)
)
XCTAssertFalse(
AppState.isAuthenticationError(
URLError(.timedOut)
)
)
XCTAssertFalse(
AppState.isAuthenticationError(
BusyBarError(message: "Device response mentioned HTTP 403")
)
)
}
}
Loading
Loading